|
|
import { SectionKey, SECTIONS } from "@/lib/sections"; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
jobTitle: string; |
|
|
}; |
|
|
|
|
|
export type AnswerMap = Record<string, string>; |
|
|
|
|
|
export type SectionScore = { |
|
|
rawPct: number; |
|
|
weighted: number; |
|
|
flags: string[]; |
|
|
}; |
|
|
|
|
|
export type ExamScore = { |
|
|
sections: Record<SectionKey, SectionScore>; |
|
|
overall: number; |
|
|
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; |
|
|
const pct = ((vv - 1) / 4) * 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; |
|
|
|
|
|
|
|
|
let attentionFlags = 0; |
|
|
let inconsistentFlags = 0; |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
if (s !== null) perSectionScores[q.section].push(s); |
|
|
|
|
|
|
|
|
if (q.section === "A" && q.prompt.toLowerCase().includes("attention check")) { |
|
|
|
|
|
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 } |
|
|
}; |
|
|
} |
|
|
|