import { SectionKey, SECTIONS } from "@/lib/sections"; /** * Keep scoring independent from question-module export shape to avoid build breaks. * We only rely on fields actually used for scoring. */ type SelectedQuestion = { baseId: string; section: SectionKey; type: "likert" | "mcq" | "text"; prompt: string; reverse?: boolean; mcqScores?: Record; }; export type CandidateMeta = { candidateName: string; passportNumber: string; // renamed from candidateId jobTitle: string; }; export type AnswerMap = Record; // baseId -> selected option / text export type SectionScore = { rawPct: number; // 0..100 weighted: number; // 0..100 flags: string[]; }; export type ExamScore = { sections: Record; overall: number; // 0..100 weighted decision: "Strong Hire" | "Hire" | "Proceed with Conditions" | "Do Not Proceed"; validity: { completionPct: number; attentionFlags: number; inconsistentFlags: number; }; }; const LIKERT_MAP: Record = { "Strongly Disagree": 1, "Disagree": 2, "Neutral": 3, "Agree": 4, "Strongly Agree": 5 }; function clamp(n: number, a: number, b: number) { return Math.max(a, Math.min(b, n)); } function avg(xs: number[]) { return xs.length ? xs.reduce((s, x) => s + x, 0) / xs.length : 0; } function scoreItemLikert(ans: string, reverse?: boolean): number | null { if (!ans) return null; const v = LIKERT_MAP[ans]; if (!v) return null; const vv = reverse ? (6 - v) : v; // reverse score const pct = ((vv - 1) / 4) * 100; // 1..5 -> 0..100 return clamp(pct, 0, 100); } function scoreItemMCQ(ans: string, mcqScores?: Record): number | null { if (!ans || !mcqScores) return null; const raw = mcqScores[ans]; if (typeof raw !== "number") return null; return clamp((raw / 5) * 100, 0, 100); } export function scoreExam(selected: SelectedQuestion[], answers: AnswerMap): ExamScore { const sections: Record = { A: { rawPct: 0, weighted: 0, flags: [] }, B: { rawPct: 0, weighted: 0, flags: [] }, C: { rawPct: 0, weighted: 0, flags: [] }, D: { rawPct: 0, weighted: 0, flags: [] }, E: { rawPct: 0, weighted: 0, flags: [] }, F: { rawPct: 0, weighted: 0, flags: [] } }; const perSectionScores: Record = { A: [], B: [], C: [], D: [], E: [], F: [] }; let answered = 0; // validity checks let attentionFlags = 0; let inconsistentFlags = 0; // simple inconsistency: reverse items answered "high" too often let reverseHighCount = 0; let reverseCount = 0; for (const q of selected) { const a = (answers[q.baseId] ?? "").trim(); if (a) answered++; let s: number | null = null; if (q.type === "likert") s = scoreItemLikert(a, q.reverse); if (q.type === "mcq") s = scoreItemMCQ(a, q.mcqScores); // text items are scored by AI separately (not included directly here) if (s !== null) perSectionScores[q.section].push(s); // attention check: detect prompt contains "attention check" if (q.section === "A" && q.prompt.toLowerCase().includes("attention check")) { // correct is Agree if (a && a !== "Agree") attentionFlags++; } if (q.reverse) { reverseCount++; const v = LIKERT_MAP[a] ?? 0; if (v >= 4) reverseHighCount++; } } if (reverseCount >= 10) { const ratio = reverseHighCount / reverseCount; if (ratio > 0.6) inconsistentFlags++; } for (const sec of Object.keys(sections) as SectionKey[]) { const raw = Math.round(avg(perSectionScores[sec])); const def = SECTIONS.find(s => s.key === sec)!; const weighted = Math.round(raw * def.weight); const flags: string[] = []; if (sec === "B" && raw < 60) flags.push("Capability threshold not met (<60)."); if (sec === "F" && raw < 60) flags.push("Functional readiness threshold not met (<60)."); if (raw < 50) flags.push("Section score below 50."); sections[sec] = { rawPct: isFinite(raw) ? raw : 0, weighted, flags }; } const overall = Math.round(Object.values(sections).reduce((s, x) => s + x.weighted, 0)); let decision: ExamScore["decision"] = "Do Not Proceed"; const failCritical = sections.B.rawPct < 60 || sections.F.rawPct < 60; if (overall >= 80 && !failCritical) decision = "Strong Hire"; else if (overall >= 70 && overall <= 79 && !failCritical) decision = "Hire"; else if (overall >= 60 && overall <= 69 && !failCritical) decision = "Proceed with Conditions"; else decision = "Do Not Proceed"; if (overall < 50 || failCritical) decision = "Do Not Proceed"; const completionPct = Math.round((answered / selected.length) * 100); return { sections, overall, decision, validity: { completionPct, attentionFlags, inconsistentFlags } }; }