marmar-context / web / context / scripts / corpus-evaluation.mjs
corpus-evaluation.mjs
Raw
import fs from "node:fs/promises";
const corpus = JSON.parse(
  await fs.readFile(
    process.argv[3] ||
      new URL("../test/fixtures/extraction-corpus.json", import.meta.url),
    "utf8",
  ),
);
const api = async (path, body) => {
  const r = await fetch(
    "http://127.0.0.1:4191/api/" + path,
    body
      ? {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(body),
          signal: AbortSignal.timeout(65000),
        }
      : undefined,
  );
  return { ok: r.ok, status: r.status, data: await r.json() };
};
const status = (await api("status")).data;
if (!status.modelConfigured)
  throw new Error("Connect the Azure model through the UI first.");
const report = {
  runAt: new Date().toISOString(),
  model: status.model,
  scope:
    "Synthetic development corpus. Not independent clinical validation; no real patient data.",
  cases: [],
};
const clean = (s) => s.toLowerCase().replace(/\s+/g, "").replaceAll("²", "2");
const matches = (f, g) =>
  f.kind === g[0] &&
  clean(f.label) === clean(g[1]) &&
  g
    .slice(2)
    .every((v) =>
      clean((f.normalizedLine ?? "") + " " + f.detail).includes(clean(v)),
    );
for (const item of corpus) {
  const row = { id: item.id, passed: false };
  const start = performance.now();
  const sources = [
    {
      id: "record-" + item.id,
      title: "Synthetic clinical note",
      kind: "Synthetic note",
      date: "2026-10-23",
      text: item.text,
    },
  ];
  try {
    const extracted = await api("extract", {
      sources,
      syntheticOnly: true,
      useModel: true,
      freshModel: true,
    });
    if (!extracted.ok) throw new Error(extracted.data.error);
    const d = extracted.data;
    row.mode = d.mode;
    row.strategy = d.strategy;
    row.attempts = d.attempts;
    row.extractionMs = d.durationMs;
    row.metrics = d.metrics;
    const usable = d.facts.filter((f) => !f.problem);
    row.required = item.required.length;
    row.matched = item.required.filter((g) =>
      usable.some((f) => matches(f, g)),
    ).length;
    row.missing = item.required.filter(
      (g) => !usable.some((f) => matches(f, g)),
    );
    row.unsafe = (item.withheld ?? []).filter((g) =>
      usable.some((f) => matches(f, g)),
    );
    row.unexpected = usable
      .filter((f) => !item.required.some((g) => matches(f, g)))
      .map((f) => ({ kind: f.kind, label: f.label, detail: f.detail }));
    row.quarantined = d.warnings?.length ?? 0;
    const review = Object.fromEntries(
      d.facts.map((f) => [f.id, f.problem ? "exclude" : "include"]),
    );
    const assessed = await api("assess", {
      sources,
      syntheticOnly: true,
      draftId: d.id,
      review,
      fingerprint: d.fingerprint,
      acknowledgeUnparsed: true,
    });
    if (!assessed.ok) throw new Error(assessed.data.error);
    row.ruleIds = assessed.data.contextual.findings.map((f) => f.ruleId);
    row.ruleCorrect =
      (!item.rule || row.ruleIds.includes(item.rule)) &&
      (!item.absentRule || !row.ruleIds.includes(item.absentRule));
    const exported = await api("reports/" + assessed.data.reportId);
    row.exportVerified =
      exported.ok &&
      exported.data.extraction.fingerprint === d.fingerprint &&
      exported.data.assessment.contextual.rulesReviewed === false;
    row.passed =
      row.missing.length === 0 &&
      row.unsafe.length === 0 &&
      row.unexpected.length === 0 &&
      row.ruleCorrect &&
      row.exportVerified;
  } catch (e) {
    row.error = e.message;
  }
  row.totalMs = Math.round(performance.now() - start);
  report.cases.push(row);
  console.log(JSON.stringify(row));
}
report.passed = report.cases.filter((c) => c.passed).length;
report.total = report.cases.length;
report.required = corpus.reduce((n, c) => n + c.required.length, 0);
report.matched = report.cases.reduce((n, c) => n + (c.matched ?? 0), 0);
report.unsafeAccepted = report.cases.reduce(
  (n, c) => n + (c.unsafe?.length ?? 0),
  0,
);
report.completed = report.cases.filter((c) => c.exportVerified).length;
const latencies = report.cases
  .filter((c) => c.exportVerified)
  .map((c) => c.totalMs)
  .sort((a, b) => a - b);
report.medianMs = latencies.length
  ? latencies[Math.floor(latencies.length / 2)]
  : null;
report.p95Ms = latencies.length
  ? latencies[Math.ceil(latencies.length * 0.95) - 1]
  : null;
await fs.writeFile(
  process.argv[2] || "validation/corpus-latest.json",
  JSON.stringify(report, null, 2) + "\n",
);
console.log(
  `Completed ${report.completed}/${report.total}; matched ${report.matched}/${report.required}; all assertions ${report.passed}/${report.total}`,
);
process.exitCode = report.passed === report.total ? 0 : 1;