Skip to content
Visibility internal Owner _ Approver _ Created _ Updated _

life-domain-quiz-qc.ts



FieldValue
TypeTypeScript
SourceProduct/Projects/Domain_Quiz/Scripts/life-domain-quiz-qc.ts
ParentProduct
GitHubProduct/Projects/Domain_Quiz/Scripts/life-domain-quiz-qc.ts

This page is auto-generated. Edit the source to change the content.



#!/usr/bin/env ts-node
/*
 * Uvilo 360° Quiz QC Checker (TypeScript / Node CLI)
 *
 * Validates one or more quiz JSON files against the Domain Quiz Prompt spec.
 * Merges capabilities from all prior versions with full spec compliance.
 *
 * Rules covered:
 *  - Freeform proportion ≈ 20% (accepted band 16–22%), ideally ≥1 freeform per section
 *  - Maximum 40 questions per quiz
 *  - Scale items must include EXACTLY two anchors: value "0" and value "10" (scores can be inverted)
 *  - Boolean labels must begin with "Yes"/"No" and, if continued, use a comma (e.g., "Yes, I …")
 *  - Boolean scoring direction heuristic (Yes higher for positive prompts; lower for negative prompts)
 *  - Multiple-choice scores monotonic in listed order
 *  - Verb consistency between question and answers (e.g., handle vs manage)
 *  - Smart typography: flag straight apostrophes; optional autofix to typographic '
 *  - Compound prompts detection (e.g., strength, endurance, flexibility)
 *  - Ordering: booleans should come before broad freeform prompts in a section
 *  - Handle validation: 3–32 chars, snake_case, globally unique across all quizzes
 *  - Slug-version consistency
 *  - Weight and maxScore bounds
 *  - Branching logic: autoFill only on freeform, filter adjacency, filterQuestion targets exist
 *  - Cross-file duplicate handle detection
 *
 * Usage:
 *   npx ts-node uvilo_quiz_qc.ts <file1.json> [file2.json ...]
 *        [--autofix-typography] [--write] [--schema <modulePath>] [--csv <out.csv>]
 *        [--weight-min 1] [--weight-max 5] [--maxscore-min 1] [--maxscore-max 10]
 *
 * Exit codes:
 *   0 on success or warnings only; 2 if any ERRORs are present
 */

import * as fs from 'fs';
import * as path from 'path';

// ---------- Types ----------
type QType = 'boolean' | 'multiple_choice' | 'scale' | 'freeform';

interface Answer {
  label: string;
  value: string;
  score: number;
}
interface Question {
  handle: string;
  type: QType;
  question: string;
  maxScore: number;
  weight: number;
  answers?: Answer[];
  filterQuestion?: string;
  filterValues?: string[];
  autoFill?: string | null;
  evalHint?: string;
}
interface Section {
  name: string;
  handle: string;
  questions: Question[];
}
interface Domain {
  slug: string;
  group: string;
  handle: string;
  version: number;
  date: string;
  name: string;
  description: string;
  instructions: string;
  analysisBotHandle: string;
  sections: Section[];
}

// ---------- CLI args ----------
const argv = process.argv.slice(2);
function takeFlag(name: string) {
  const i = argv.indexOf(name);
  if (i !== -1) { argv.splice(i, 1); return true; }
  return false;
}
function takeOption(name: string) {
  const i = argv.indexOf(name);
  if (i !== -1 && i < argv.length - 1) {
    const v = argv[i + 1];
    argv.splice(i, 2);
    return v;
  }
  return undefined;
}

const AUTOFIX = takeFlag('--autofix-typography');
const WRITE = takeFlag('--write');
const SCHEMA_PATH = takeOption('--schema');
const CSV_PATH = takeOption('--csv');

const WEIGHT_MIN = Number(takeOption('--weight-min') ?? 1);
const WEIGHT_MAX = Number(takeOption('--weight-max') ?? 5);
const MAXSCORE_MIN = Number(takeOption('--maxscore-min') ?? 1);
const MAXSCORE_MAX = Number(takeOption('--maxscore-max') ?? 10);

const FILES = argv.filter((a) => !a.startsWith('--'));

if (FILES.length === 0) {
  console.error(
    'Usage: npx ts-node uvilo_quiz_qc.ts <file1.json> [file2.json ...] [--autofix-typography] [--write] [--schema <modulePath>] [--csv <out.csv>]',
  );
  process.exit(1);
}

// ---------- Heuristics & utils ----------
const POSITIVE_HINT_WORDS = [
  'avoid', 'maintain', 'meet', 'track', 'routinely', 'adhere', 'follow',
  'improve', 'increase', 'build', 'supportive', 'prepare', 'plan',
  'repair', 'healthy', 'consistent', 'aligned', 'secure', 'save',
];
const NEGATIVE_HINT_WORDS = [
  'pain', 'limitation', 'limitations', 'injury', 'injuries', 'smoking',
  'excess', 'burnout', 'poor', 'allergies', 'chronic', 'condition',
  'conditions', 'toxic', 'draining', 'guilty', 'anxious', 'overwhelmed',
];

const VERB_SETS: Array<Set<string>> = [
  new Set(['handle', 'manage']),
  new Set(['prioritize', 'value']),
  new Set(['rate', 'assess', 'evaluate']),
  new Set(['feel', 'experience']),
  new Set(['express', 'show', 'demonstrate']),
  new Set(['set', 'keep', 'maintain']),
  new Set(['contribute', 'give', 'donate']),
];

function isString(x: unknown): x is string {
  return typeof x === 'string';
}

function textHasStraightApostrophe(s: string | undefined): boolean {
  if (!s) return false;
  return /[A-Za-z]'[A-Za-z]/.test(s);
}

function toSmartApostrophes(s: string): string {
  return s.replace(/([A-Za-z])'(?=[A-Za-z])/g, '$1\u2019');
}

// Scale anchors: check values "0" and "10" exist (scores CAN be inverted per spec Rule 5)
function hasScaleAnchors(q: Question): boolean {
  if (q.type !== 'scale') return true;
  const ans = q.answers || [];
  if (ans.length !== 2) return false;
  const hasValue0 = ans.some((a) => a.value === '0');
  const hasValue10 = ans.some((a) => a.value === '10');
  return hasValue0 && hasValue10;
}

function booleanLabelOk(label: string): boolean {
  if (!isString(label)) return false;
  if (
    label.startsWith('Yes \u2014') || label.startsWith('No \u2014') ||
    label.startsWith('Yes \u2013') || label.startsWith('No \u2013')
  ) return false;
  if (label.startsWith('Yes') || label.startsWith('No')) {
    if (label === 'Yes' || label === 'No') return true;
    if (/^(Yes|No),($|\s)/.test(label)) return true;
    if (/^(Yes|No)\s+/.test(label)) return false;
    return true;
  }
  return false;
}

function booleanScoringDirectionOk(q: Question): boolean {
  if (q.type !== 'boolean') return true;
  const ans = q.answers || [];
  if (ans.length !== 2) return true;
  const yes = ans.find((a) => isString(a.label) && a.label.startsWith('Yes'));
  const no = ans.find((a) => isString(a.label) && a.label.startsWith('No'));
  if (!yes || !no) return false;
  const text = (q.question || '').toLowerCase();
  const pos = POSITIVE_HINT_WORDS.some((w) => text.includes(w));
  const neg = NEGATIVE_HINT_WORDS.some((w) => text.includes(w));
  if (pos && !neg) return yes.score > no.score;
  if (neg && !pos) return yes.score < no.score;
  return true;
}

function mcMonotonic(q: Question): boolean {
  if (q.type !== 'multiple_choice') return true;
  const ans = q.answers || [];
  if (ans.length < 2) return false;
  const scores = ans.map((a) => a.score);
  if (scores.some((s) => typeof s !== 'number')) return false;
  const nondec = scores.every((s, i, arr) => i === 0 || arr[i - 1]! <= s);
  const noninc = scores.every((s, i, arr) => i === 0 || arr[i - 1]! >= s);
  return nondec || noninc;
}

function detectVerbMismatch(q: Question): boolean {
  const qtext = (q.question || '').toLowerCase();
  const answersText = (q.answers || []).map((a) => (a.label || '').toLowerCase()).join(' ');
  for (const vs of VERB_SETS) {
    const inQ = Array.from(vs).filter((v) => new RegExp(`\\b${v}\\b`).test(qtext)).sort();
    const inA = Array.from(vs).filter((v) => new RegExp(`\\b${v}\\b`).test(answersText)).sort();
    if (inQ.length && inA.length && inQ.join(',') !== inA.join(',')) return true;
  }
  return false;
}

function detectCompoundPrompt(q: Question): boolean {
  const qt = q.question || '';
  const qtext = qt.toLowerCase();
  const parts = qtext.split(/[,/]|(?:\b(?:and|or)\b)/).map((s) => s.trim()).filter(Boolean);
  const tokens = parts.filter((t) => t.length >= 5);
  return qt.includes(',') && new Set(tokens).size >= 3;
}

function sectionHasFreeform(sec: Section): boolean {
  return (sec.questions || []).some((q) => q.type === 'freeform');
}

function walkTexts(domain: Domain, fn: (s: string) => string) {
  if (isString(domain.description)) domain.description = fn(domain.description);
  if (isString(domain.instructions)) domain.instructions = fn(domain.instructions);
  for (const sec of domain.sections || []) {
    if (isString(sec.name)) sec.name = fn(sec.name);
    for (const q of sec.questions || []) {
      if (isString(q.question)) q.question = fn(q.question);
      if (Array.isArray(q.answers)) {
        for (const a of q.answers) {
          if (isString(a.label)) a.label = fn(a.label);
        }
      }
    }
  }
}

// ---------- I/O helpers ----------
function readJson(p: string) {
  const raw = fs.readFileSync(p, 'utf8');
  return JSON.parse(raw);
}

function writeJson(p: string, obj: unknown) {
  fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', 'utf8');
}

// ---------- Optional schema validation ----------
async function optionalSchemaValidate(obj: unknown): Promise<string[] | null> {
  if (!SCHEMA_PATH) return null;
  const full = path.isAbsolute(SCHEMA_PATH)
    ? SCHEMA_PATH
    : path.resolve(process.cwd(), SCHEMA_PATH);
  if (!fs.existsSync(full)) return [`Schema module not found: ${full}`];
  try {
    const mod = require(full);
    if (mod?.UploadQuizSchema?.safeParse) {
      const res = mod.UploadQuizSchema.safeParse(obj);
      if (res.success) return null;
      else return [String(res.error)];
    }
    if (typeof mod?.default === 'function') {
      const result = mod.default(obj);
      if (result && result.success) return null;
      else return result?.errors || ['Schema validation failed (unknown format).'];
    }
    return ['Schema module does not export UploadQuizSchema (zod) or default validate()'];
  } catch (e: unknown) {
    return ['Schema import/validation error: ' + (e instanceof Error ? e.message : String(e))];
  }
}

// ---------- Checks ----------
type IssueLevel = 'OK' | 'WARN' | 'ERROR';
interface Issue {
  file: string;
  level: IssueLevel;
  where: string;
  rule: string;
  message: string;
}

interface SummaryRow {
  file: string;
  name: string;
  sections: number;
  questions: number;
  boolean: number;
  multiple_choice: number;
  scale: number;
  freeform: number;
  totalScore: number;
}

function summarizeDim(domain: Domain): SummaryRow {
  let qTotal = 0, scoreTotal = 0;
  const counts = { boolean: 0, multiple_choice: 0, scale: 0, freeform: 0 };
  for (const s of domain.sections || []) {
    for (const q of s.questions || []) {
      qTotal += 1;
      counts[q.type as keyof typeof counts] = (counts[q.type as keyof typeof counts] ?? 0) + 1;
      scoreTotal += q.maxScore ?? 0;
    }
  }
  return {
    file: '',
    name: domain.name,
    sections: (domain.sections || []).length,
    questions: qTotal,
    boolean: counts.boolean,
    multiple_choice: counts.multiple_choice,
    scale: counts.scale,
    freeform: counts.freeform,
    totalScore: scoreTotal,
  };
}

function validateDim(
  domain: Domain,
  fname: string,
): { issues: Issue[]; summary: SummaryRow; handles: string[] } {
  const issues: Issue[] = [];
  const sum = summarizeDim(domain);
  sum.file = fname;

  // -- Global checks --

  // 0a) Slug-version consistency (from Skill)
  const slugMatch = domain.slug?.match(/_(\d+)$/);
  if (slugMatch) {
    const slugVersion = parseInt(slugMatch[1], 10);
    if (slugVersion !== domain.version) {
      issues.push({
        file: fname, level: 'ERROR', where: 'global', rule: 'slug_version_mismatch',
        message: `Slug version mismatch: slug \u2018${domain.slug}\u2019 implies v${slugVersion} but version field is ${domain.version}.`,
      });
    }
  }

  // 0b) Max 40 questions per quiz (from Skill, spec Rule 1)
  if (sum.questions > 40) {
    issues.push({
      file: fname, level: 'ERROR', where: 'global', rule: 'max_questions',
      message: `Quiz has ${sum.questions} questions (max 40).`,
    });
  }

  // 1) Freeform share: 16\u201322% (spec Rule 1 \u2014 NOT 18\u201322%)
  const freeformPct = sum.questions ? (sum.freeform / sum.questions) * 100 : 0;
  const low = 16, high = 22;
  if (!(freeformPct >= low && freeformPct <= high)) {
    issues.push({
      file: fname, level: 'WARN', where: 'global', rule: 'freeform_share',
      message: `Freeform ${sum.freeform}/${sum.questions} (${freeformPct.toFixed(1)}%) outside ${low}\u2013${high}% band.`,
    });
  }

  // 1b) \u22651 freeform per section if feasible
  (domain.sections || []).forEach((sec, i) => {
    if (!sectionHasFreeform(sec)) {
      issues.push({
        file: fname, level: 'WARN',
        where: `Section ${i + 1} (\u2018${sec.name || sec.handle}\u2019)`,
        rule: 'per_section_freeform',
        message: 'No freeform item in section.',
      });
    }
  });

  // Build handle index for branching logic validation
  const allHandles: string[] = [];
  const handleSet = new Set<string>();
  const handleToQuestion = new Map<string, Question>();
  for (const sec of domain.sections || []) {
    for (const q of sec.questions || []) {
      if (q.handle) {
        handleSet.add(q.handle);
        handleToQuestion.set(q.handle, q);
      }
    }
  }

  // Unique handles within domain (from Codebase)
  const seen = new Map<string, number>();

  // -- Per-section / per-question checks --
  (domain.sections || []).forEach((sec, si) => {
    let seenFreeform = false;

    (sec.questions || []).forEach((q, qi) => {
      const loc = `Section ${si + 1} \u203a Q${qi + 1} (\u2018${q.handle || '???'}\u2019)`;

      // Handle: missing
      if (!q.handle) {
        issues.push({
          file: fname, level: 'ERROR', where: loc,
          rule: 'missing_handle', message: 'Question has no handle.',
        });
      } else {
        allHandles.push(q.handle);

        // Handle: duplicate within file
        const prev = seen.get(q.handle) ?? 0;
        if (prev === 1) {
          issues.push({
            file: fname, level: 'ERROR', where: loc,
            rule: 'duplicate_handle_within_file',
            message: `Duplicate question handle within file: \u2018${q.handle}\u2019.`,
          });
        }
        seen.set(q.handle, prev + 1);

        // Handle: length 3\u201332 (from Skill, spec Rule 10)
        if (q.handle.length < 3 || q.handle.length > 32) {
          issues.push({
            file: fname, level: 'ERROR', where: loc,
            rule: 'handle_length',
            message: `Handle \u2018${q.handle}\u2019 is ${q.handle.length} chars (must be 3\u201332).`,
          });
        }
      }

      // Weight & maxScore bounds (from Codebase)
      if (!(Number.isFinite(q.weight) && q.weight >= WEIGHT_MIN && q.weight <= WEIGHT_MAX)) {
        issues.push({
          file: fname, level: 'WARN', where: loc,
          rule: 'weight_bounds',
          message: `Weight ${q.weight} outside [${WEIGHT_MIN}, ${WEIGHT_MAX}] (adjust bounds via CLI if intentional).`,
        });
      }
      if (!(Number.isFinite(q.maxScore) && q.maxScore >= MAXSCORE_MIN && q.maxScore <= MAXSCORE_MAX)) {
        issues.push({
          file: fname, level: 'WARN', where: loc,
          rule: 'maxscore_bounds',
          message: `maxScore ${q.maxScore} outside [${MAXSCORE_MIN}, ${MAXSCORE_MAX}] (adjust bounds via CLI if intentional).`,
        });
      }

      // Smart typography
      if (textHasStraightApostrophe(q.question)) {
        issues.push({
          file: fname, level: 'WARN', where: loc,
          rule: 'smart_typography',
          message: 'Straight apostrophe in question; use typographic \u2019.',
        });
      }

      // -- Type-specific checks --

      if (q.type === 'boolean') {
        const ans = q.answers || [];
        if (ans.length !== 2) {
          issues.push({
            file: fname, level: 'WARN', where: loc,
            rule: 'boolean_two_answers',
            message: 'Boolean should have exactly two answers (Yes/No).',
          });
        }
        let labelsOk = true;
        for (const a of ans) {
          if (textHasStraightApostrophe(a.label)) {
            issues.push({
              file: fname, level: 'WARN', where: loc,
              rule: 'smart_typography',
              message: `Straight apostrophe in boolean label \u2018${a.label}\u2019.`,
            });
          }
          if (!booleanLabelOk(a.label)) labelsOk = false;
        }
        if (!labelsOk) {
          issues.push({
            file: fname, level: 'ERROR', where: loc,
            rule: 'boolean_labels_format',
            message: 'Boolean labels must begin with \u2018Yes\u2019/\u2018No\u2019 and use a comma for continuation.',
          });
        }
        if (!booleanScoringDirectionOk(q)) {
          issues.push({
            file: fname, level: 'WARN', where: loc,
            rule: 'boolean_scoring_direction',
            message: 'Possible scoring inversion (Yes vs No) relative to wording.',
          });
        }
      }

      if (q.type === 'scale') {
        if (!hasScaleAnchors(q)) {
          issues.push({
            file: fname, level: 'ERROR', where: loc,
            rule: 'scale_two_anchors',
            message: 'Scale must include exactly two anchors with value \u20180\u2019 and value \u201810\u2019 (scores can be inverted for negative questions).',
          });
        }
        (q.answers || []).forEach((a) => {
          if (textHasStraightApostrophe(a.label)) {
            issues.push({
              file: fname, level: 'WARN', where: loc,
              rule: 'smart_typography',
              message: `Straight apostrophe in scale label \u2018${a.label}\u2019.`,
            });
          }
        });

Note: This file exceeds 500 lines. Showing the first 500 lines. View the complete file on GitHub