/* ================================================================ Model Answer Authoring — entire feature module ---------------------------------------------------------------- Backed by the Bengali pipeline at POST /api/questions/:id/answer-key GET /api/questions/:id/answer-key POST /api/questions/:id/answer-key/approve POST /api/tests/:id/answer-keys GET /api/tests/:id/answer-keys The screen is self-contained — pick a test, list its questions on the left, generate / view / approve the 4-stage answer-key on the right. ================================================================ */ const AK_Icon = window.AppUI.Icon; const AK_Api = window.AppApi; /* ---------- shared helpers ---------- */ const AK_LANG_LABEL = { bn: "বাংলা", hi: "हिन्दी", en: "English" }; function ak_useTests() { const [tests, setTests] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { let alive = true; AK_Api.get("/tests") .then(d => { if (alive) { setTests((d || []).filter(t => Number(t.questions) > 0)); setLoading(false); } }) .catch(() => { if (alive) setLoading(false); }); return () => { alive = false; }; }, []); return { tests, loading }; } function ak_useQuestions(testDisplayId) { const [questions, setQuestions] = useState([]); const [loading, setLoading] = useState(false); useEffect(() => { if (!testDisplayId) { setQuestions([]); return; } let alive = true; setLoading(true); AK_Api.get(`/tests/${testDisplayId}/questions`) .then(d => { if (alive) { setQuestions(d || []); setLoading(false); } }) .catch(() => { if (alive) setLoading(false); }); return () => { alive = false; }; }, [testDisplayId]); return { questions, loading }; } /* Hash a question id to a pleasant accent for chips. */ const AK_PALETTE = ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", "var(--chart-6)"]; const ak_colorFor = (qid, all) => { const idx = Math.max(0, all.findIndex(q => q.id === qid)); return AK_PALETTE[idx % AK_PALETTE.length]; }; /* ================================================================ ROOT — orchestrates test pick, question list, right-pane viewer ================================================================ */ const AnswerKeyAuthoring = ({ setScreen }) => { const { tests, loading: testsLoading } = ak_useTests(); const [testId, setTestId] = useState(null); const test = tests.find(t => t.id === testId) || null; // Auto-pick first test once the list loads (skip if URL hash has a hint). useEffect(() => { if (!testId && tests.length) { const fromHash = decodeURIComponent((location.hash.match(/test=([^&]+)/) || [])[1] || ""); const found = fromHash && tests.find(t => t.id === fromHash); setTestId(found ? found.id : tests[0].id); } }, [tests, testId]); const { questions, loading: questionsLoading } = ak_useQuestions(test?.id); // answer keys keyed by question_id const [keys, setKeys] = useState({}); // { [qid]: AnswerKey } const [running, setRunning] = useState({}); // { [qid]: 'running'|'error' } const [bulkProgress, setBulkProgress] = useState(null); // null | { running, total, succeeded, failed } const [activeQid, setActiveQid] = useState(null); const [language, setLanguage] = useState("en"); const [printable, setPrintable] = useState(false); // Hydrate any persisted answer-keys when test/questions arrive. useEffect(() => { if (!test) return; let alive = true; AK_Api.get(`/tests/${test.id}/answer-keys`) .then(rows => { if (!alive) return; const byId = {}; for (const r of rows || []) byId[r.question_id] = r; setKeys(byId); }) .catch(() => {}); return () => { alive = false; }; }, [test?.id]); useEffect(() => { if (!activeQid && questions.length) setActiveQid(questions[0].id); }, [questions, activeQid]); const activeQ = questions.find(q => q.id === activeQid) || null; const activeKey = activeQid ? keys[activeQid] : null; const runForQuestion = async (qid) => { setRunning(r => ({ ...r, [qid]: "running" })); try { const ak = await AK_Api.post(`/questions/${qid}/answer-key?lang=${language}`); setKeys(prev => ({ ...prev, [qid]: ak })); setRunning(r => { const c = { ...r }; delete c[qid]; return c; }); } catch (e) { console.error("[answer-key]", e); setRunning(r => ({ ...r, [qid]: "error" })); } }; const approveQuestion = async (qid) => { try { await AK_Api.post(`/questions/${qid}/answer-key/approve`); setKeys(prev => prev[qid] ? { ...prev, [qid]: { ...prev[qid], status: "approved" } } : prev); } catch (e) { console.error(e); } }; const runForAll = async (skipExisting) => { if (!test) return; const todo = questions .map(q => q.id) .filter(id => !skipExisting || !keys[id]); if (!todo.length) return; setBulkProgress({ running: 0, total: todo.length, succeeded: 0, failed: 0 }); // Run sequentially client-side so the user sees per-question updates; the // backend is the rate-limiter (it caps concurrency anyway). let succeeded = 0, failed = 0; for (let i = 0; i < todo.length; i++) { const qid = todo[i]; setBulkProgress({ running: i + 1, total: todo.length, succeeded, failed }); try { await runForQuestion(qid); succeeded++; } catch { failed++; } } setBulkProgress({ running: todo.length, total: todo.length, succeeded, failed }); setTimeout(() => setBulkProgress(null), 3500); }; return (
runForAll(true)} onRegenerateAll={() => runForAll(false)} bulkProgress={bulkProgress} /> {!test ? (
{testsLoading ? "Loading tests…" : "No tests with parsed questions yet — upload one from the Test Library first."}
) : printable ? ( ) : (
activeQid && runForQuestion(activeQid)} onApprove={() => activeQid && approveQuestion(activeQid)} language={language} />
)}
); }; /* ================================================================ HEADER — test picker + bulk actions + progress strip ================================================================ */ const AK_Header = ({ tests, testId, setTestId, testsLoading, language, setLanguage, printable, setPrintable, questions, keys, onRunAll, onRegenerateAll, bulkProgress, }) => { const total = questions.length; const generated = questions.filter(q => keys[q.id]).length; const approved = questions.filter(q => keys[q.id]?.status === "approved").length; return (
A
Model Answer Keys
scope · perfect answer · logic · deduction-proofing
{["bn", "hi", "en"].map(l => ( ))}
{/* Two counters and three buttons. Without wrapping this row measures 553px, which on a phone widened the whole layout viewport rather than merely overflowing — every other element then rendered at the wrong scale. flexWrap + minWidth:0 keeps it inside the screen. */} {generated}/{total} generated {approved}/{total} approved {bulkProgress && (
Running pipeline · {bulkProgress.running} of {bulkProgress.total} ✓ {bulkProgress.succeeded} · ✕ {bulkProgress.failed}
)}
); }; /* ================================================================ QUESTION SIDEBAR — sectioned list with status dots ================================================================ */ const AK_QuestionSidebar = ({ questions, keys, running, activeQid, onPick, loading }) => { // Group by inferred section letter (first non-digit prefix in q_no) — falls // back to a single "Questions" bucket if numbering is plain. const groups = useMemo(() => { const m = new Map(); questions.forEach(q => { const key = String(q.num ?? "").match(/^[A-Z]/) ? String(q.num)[0] : "•"; if (!m.has(key)) m.set(key, []); m.get(key).push(q); }); return [...m.entries()].map(([k, items]) => ({ k, items })); }, [questions]); return ( ); }; const AK_StatusBadge = ({ ak, running }) => { if (running === "running") { return ; } if (running === "error") { return ✕; } if (!ak) return ; if (ak.status === "approved") return ✓; return ; }; /* ================================================================ RIGHT PANE — 4 stacked cards or empty state ================================================================ */ const AK_RightPane = ({ question, answerKey, running, error, onRun, onApprove, language }) => { if (!question) { return
Pick a question on the left.
; } return (
{!answerKey && !running && !error && (
✨
No model answer yet
Click Generate above. The pipeline runs scope → perfect answer → logic → deduction-proofing (~50s on Gemini Pro).
)} {running && } {error && !running && (
The pipeline failed. Open the eavesdropper (/api/_eavesdrop/stream) to see why, then retry.
)} {answerKey && } {answerKey && } {answerKey && } {answerKey && }
); }; const AK_QuestionHeader = ({ q, ak, running, error, onRun, onApprove, language }) => (
Q{q.num}
{q.type} {q.maxMarks} marks {ak && ( {ak.status === "approved" ? "✓ approved" : "draft"} )} {ak && {AK_LANG_LABEL[ak.language] || ak.language}} {ak && ak.status !== "approved" && ( )}
{q.text}
); /* ---------- Running indicator ---------- */ const AK_RunningCard = ({ q, language }) => { const stages = [ "Identifying scope & syllabus anchor", "Composing the perfect answer + flow", "Explaining the chemistry / logic per criterion", "Generating deduction-proof variants + rebuttals", ]; // No real per-stage SSE yet — animate sequentially for honest UX. const [now] = useState(Date.now()); const elapsed = (Date.now() - now) / 1000; return (
Pipeline running for Q{q.num} ({AK_LANG_LABEL[language] || language}) ~50s typical
{stages.map((s, i) => (
› {s}
))}
); }; /* ---------- Lightweight Bengali-friendly markdown renderer ---------- The pipeline prompts produce text like: - paragraphs separated by blank lines - **bold** spans - "* " bullet list rows - "1." / "১." numbered list rows - indented " " continuation rows (inline reactions, etc.) We parse those into proper block elements so long answers flow naturally without `
` quirks or fixed heights cutting content off. */
const AK_BN_NUM = /^([0-9]+|[০-৯]+)[.)]\s+/;
const AK_INLINE_BOLD = /(\*\*[^*]+\*\*)/g;

function ak_renderInline(s) {
  if (s == null) return null;
  const parts = String(s).split(AK_INLINE_BOLD);
  return parts.map((p, i) =>
    p.startsWith("**") && p.endsWith("**")
      ? {p.slice(2, -2)}
      : {p}
  );
}

function ak_renderMarkdown(text, opts = {}) {
  if (!text) return null;
  const className = opts.className || "bn";
  const baseStyle = {
    fontSize: opts.fontSize || 13.5,
    lineHeight: 1.6,
    color: opts.color || "var(--ink-0)",
    fontFamily: opts.font || "var(--font-serif)",
    overflowWrap: "anywhere",
    wordBreak: "break-word",
  };
  const lines = String(text).replace(/\r\n/g, "\n").split("\n");
  const blocks = [];
  let i = 0;
  let key = 0;
  while (i < lines.length) {
    const raw = lines[i];
    const trimmed = raw.trim();

    // Blank line → paragraph break (rendered as gap between blocks).
    if (!trimmed) { i++; continue; }

    // Bullet block — collect consecutive "* " (or "- ") items, treating
    // indented continuation lines as part of the previous item.
    if (/^[*\-]\s+/.test(trimmed)) {
      const items = [];
      while (i < lines.length) {
        const r = lines[i];
        const t = r.trim();
        if (/^[*\-]\s+/.test(t)) {
          items.push(t.replace(/^[*\-]\s+/, ""));
          i++;
        } else if (/^\s{2,}\S/.test(r) && items.length) {
          // Continuation of the last bullet (indented).
          items[items.length - 1] += "\n" + t;
          i++;
        } else {
          break;
        }
      }
      blocks.push(
        
    {items.map((it, j) => (
  • {ak_renderInline(it)}
  • ))}
); continue; } // Numbered block (English or Bengali digits). if (AK_BN_NUM.test(trimmed)) { const items = []; while (i < lines.length) { const r = lines[i]; const t = r.trim(); const m = t.match(AK_BN_NUM); if (m) { items.push(t.slice(m[0].length)); i++; } else if (/^\s{2,}\S/.test(r) && items.length) { items[items.length - 1] += "\n" + t; i++; } else { break; } } blocks.push(
    {items.map((it, j) => (
  1. {ak_renderInline(it)}
  2. ))}
); continue; } // Plain paragraph — keep going until we hit a blank line or a list marker. const para = [trimmed]; i++; while (i < lines.length) { const t = lines[i].trim(); if (!t) break; if (/^[*\-]\s+/.test(t) || AK_BN_NUM.test(t)) break; para.push(t); i++; } blocks.push(

{ak_renderInline(para.join("\n"))}

); } return blocks; } /* ---------- Card 1: Scope ---------- */ const AK_ScopeCard = ({ ak }) => { const s = ak.scope; if (!s) return null; return (
{ak_renderMarkdown(s.scope_summary)}
{k}) : "—"}/>
); }; /* ---------- Card 2: Perfect answer + flow + key points ---------- */ const AK_PerfectCard = ({ ak, q }) => { const p = ak.perfect; if (!p) return null; return (
{ak_renderMarkdown(p.perfect_answer, { fontSize: 14 })}
{p.answer_flow?.length > 0 && ( )} {p.diagram_hint && (
✎ {p.diagram_hint}
)} {p.key_points?.length > 0 && (
Key points · rubric mapping
{p.key_points.map((kp, i) => ( ))}
Criterion What the answer says m
{kp.criterion_id} {kp.point} {kp.mark_weight}
Σ {p.key_points.reduce((a, k) => a + Number(k.mark_weight || 0), 0)} / {q.maxMarks}
)}
); }; /* ---------- Card 3: Logic behind perfection ---------- */ const AK_LogicCard = ({ ak }) => { const l = ak.logic; if (!l) return null; return (
{ak_renderMarkdown(l.logic_summary)}
{l.per_criterion_logic?.length > 0 && (
{l.per_criterion_logic.map((c, i) => (
{c.criterion_id} {c.principle} {c.evidence_in_answer && c.evidence_in_answer !== "(implied)" && ( cites: “{c.evidence_in_answer}” )}
{ak_renderMarkdown(c.reasoning, { fontSize: 13 })}
))}
)} {l.citations?.length > 0 && (
Citations
{l.citations.map((c, i) => ( {c.kind}{c.label}{c.page ? ` · p.${c.page}` : ""} ))}
)} {l.alternative_correct_explanations?.length > 0 && ( )}
); }; /* ---------- Card 4: Deduction proofing ---------- */ const AK_DeductionCard = ({ ak }) => { const d = ak.deduction_proof; if (!d) return null; return (
{ak_renderMarkdown(d.deduction_proof_summary)}
{d.accept_also?.map((a, i) => (
✓ {a.variant} {a.max_credit}m
{ak_renderMarkdown(a.why_acceptable, { fontSize: 12, color: "var(--ink-2)" })}
))}
{d.reject_also?.map((a, i) => (
✕ {a.variant} {a.credit_if_partial > 0 && ( +{a.credit_if_partial}m )}
{ak_renderMarkdown(a.why_rejected, { fontSize: 12, color: "var(--ink-2)" })}
))}
{d.marking_discontinuities?.length > 0 && ( {d.marking_discontinuities.map((m, i) => (
{m.trigger} −{m.deduction}m
{ak_renderMarkdown(m.rationale, { fontSize: 12, color: "var(--ink-2)" })}
))}
)} {d.common_objections?.length > 0 && ( {d.common_objections.map((o, i) => (
›“{o.objection}”
{ak_renderMarkdown(o.rebuttal, { fontSize: 12.5 })}
))}
)} {d.minimum_acceptable_representation && (
{d.minimum_acceptable_representation}
)} {d.examiner_checklist?.length > 0 && (
    {d.examiner_checklist.map((c, i) =>
  1. {c}
  2. )}
)}
); }; /* ================================================================ Layout primitives ================================================================ */ const AkSection = ({ title, accent, id, children }) => (
{title}
{children}
); const Sub = ({ title, color, children }) => (
{title}
{children}
); const KV = ({ label, value }) => ( <>
{label}
{value || "—"}
); const ListBlock = ({ title, items, numbered, accent }) => { if (!items || items.length === 0) return null; const color = accent === "amber" ? "var(--amber)" : "var(--ink-3)"; const Tag = numbered ? "ol" : "ul"; return (
{title}
{items.map((it, i) =>
  • {it}
  • )}
    ); }; const AK_TwoCol = ({ children }) => (
    {children}
    ); const th = { textAlign:"left", padding:"6px 8px", fontSize: 10.5, color:"var(--ink-3)", textTransform:"uppercase", letterSpacing:".06em", borderBottom:"1px solid var(--line)" }; const td = { padding:"6px 8px", fontSize: 12.5, color:"var(--ink-1)", borderBottom:"1px solid var(--line-soft)", verticalAlign:"top" }; const tdMono = { ...td, fontFamily:"var(--font-mono)", fontSize: 11, color:"var(--ink-2)", whiteSpace:"nowrap" }; const proofCard = (col) => ({ padding:"8px 10px", background:"var(--bg-2)", border:"1px solid var(--line-soft)", borderLeft: `2px solid ${col}`, borderRadius: 4, }); /* ================================================================ Print view — flatten every answer-key onto one scrollable A4-styled column ================================================================ */ const AK_PrintView = ({ test, questions, keys }) => { const generated = questions.filter(q => keys[q.id]); return (
    {/* A4 on paper, but 210mm is 794px — on a phone that forced the whole layout viewport wider than the screen. Cap it for the screen; the @page rule in print CSS is what actually governs the printout. */}
    {(window.APP.CURRENT_SCHOOL?.name || window.BRAND.orgName)} · Model Answer Key · v1 · {new Date().toISOString().slice(0, 10)}

    {test.title} {test.id} · {test.subject || "—"} · {test.className || "—"} · {generated.length} / {questions.length} questions

    {generated.length === 0 && (

    No answer keys generated yet. Go back, click Generate missing, then return here.

    )} {generated.map(q => { const ak = keys[q.id]; return (

    Q{q.num} · {q.type} · {q.maxMarks} m · {ak.status}

    {q.text}

    {ak.scope && (
    Scope
    {ak.scope.scope_summary}
    {ak.scope.syllabus_anchor}
    )} {ak.perfect && (
    Perfect answer
    {ak.perfect.perfect_answer}
    )} {ak.logic?.logic_summary && (
    Logic
    {ak.logic.logic_summary}
    )} {ak.deduction_proof?.deduction_proof_summary && (
    Deduction-proofing
    {ak.deduction_proof.deduction_proof_summary}
    )}
    ); })}
    ); }; const PrintLabel = ({ children }) => (
    {children}
    ); window.AnswerKeyAuthoring = AnswerKeyAuthoring;