Visibility internal Owner _ Approver _ Created _ Updated _
life-domain-quiz-qc.ts
| Field | Value |
|---|---|
| Type | TypeScript |
| Source | Product/Projects/Domain_Quiz/Scripts/life-domain-quiz-qc.ts |
| Parent | Product |
| GitHub | Product/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