NextReact / src /lib /scoring.ts
Klnimri's picture
Upload 4 files
a63012b verified
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 }
};
}