Spaces:
Sleeping
Sleeping
| 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<string, number>; | |
| }; | |
| export type CandidateMeta = { | |
| candidateName: string; | |
| passportNumber: string; // renamed from candidateId | |
| jobTitle: string; | |
| }; | |
| export type AnswerMap = Record<string, string>; // baseId -> selected option / text | |
| export type SectionScore = { | |
| rawPct: number; // 0..100 | |
| weighted: number; // 0..100 | |
| flags: string[]; | |
| }; | |
| export type ExamScore = { | |
| sections: Record<SectionKey, SectionScore>; | |
| 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<string, number> = { | |
| "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<string, number>): 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<SectionKey, SectionScore> = { | |
| 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<SectionKey, number[]> = { 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 } | |
| }; | |
| } | |