mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
Every staged fact carried a sourceText the model echoed back, stored verbatim as 'the exact verbatim span of the document the fact came from'. Nothing checked that the span existed. A paraphrase, a normalised rendering, or an outright invention was persisted and shown to the reviewer as a transcription of their own clinical record — an anchor pointing at nothing, or at a span the document never contained. Treat the echo as a lookup key instead. anchorSourceText resolves it against the extracted text (exact, then whitespace-collapsed and case-folded to absorb OCR reflow) and stores the span the document actually carries, with its offset. A quote that matches nothing is dropped and the fact is flagged unanchored; vision mode has no extracted text at all, so its facts are unanchored by construction. Rows written before this resolve to unanchored on read — their quote is preserved but never presented as checked.
244 lines
7.5 KiB
TypeScript
244 lines
7.5 KiB
TypeScript
/**
|
|
* v1.25 (W-DOCS-IN) — extraction mapping safety tests.
|
|
*
|
|
* The mapping is the safety boundary: it must reproduce STATED facts only and
|
|
* never let a code, value, or status leak across resource types or past the
|
|
* confidence gate. These tests pin "extract, never interpret" at the unit
|
|
* level with a canned provider response (no network).
|
|
*/
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
InboundExtractError,
|
|
runInboundExtraction,
|
|
} from "@/lib/documents/extract";
|
|
import type { AIProvider } from "@/lib/ai/types";
|
|
import type {
|
|
ConditionFactData,
|
|
MedicationStatementFactData,
|
|
ObservationFactData,
|
|
} from "@/lib/validations/inbound-documents";
|
|
|
|
/** A provider that returns one canned completion regardless of params. */
|
|
function fakeProvider(content: string): AIProvider {
|
|
return {
|
|
// Only `generateCompletion` is exercised by the extractor.
|
|
generateCompletion: async () => ({ content }),
|
|
} as unknown as AIProvider;
|
|
}
|
|
|
|
describe("runInboundExtraction — stated-status-only mapping", () => {
|
|
it("keeps a stated ICD-10 code on a Condition and drops a mismatched system", async () => {
|
|
const provider = fakeProvider(
|
|
JSON.stringify({
|
|
reportDate: "2026-01-02",
|
|
kind: "DOCTOR_REPORT",
|
|
facts: [
|
|
{
|
|
type: "CONDITION",
|
|
label: "Type 2 diabetes mellitus",
|
|
code: "E11.9",
|
|
codeSystem: "ICD10",
|
|
clinicalStatus: "active",
|
|
verificationStatus: "confirmed",
|
|
effectiveDate: "2025-12-01",
|
|
confidence: 0.9,
|
|
sourceText: "Dx: Type 2 diabetes mellitus (E11.9)",
|
|
},
|
|
{
|
|
type: "CONDITION",
|
|
label: "Some finding",
|
|
code: "12345-6",
|
|
codeSystem: "LOINC", // wrong system for a Condition → dropped
|
|
confidence: 0.9,
|
|
sourceText: "finding",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const result = await runInboundExtraction({
|
|
provider,
|
|
providerType: "mock",
|
|
});
|
|
|
|
expect(result.reportDate).toBe("2026-01-02");
|
|
expect(result.facts).toHaveLength(2);
|
|
|
|
const first = result.facts[0]!.data as ConditionFactData;
|
|
expect(first.code).toBe("E11.9");
|
|
expect(first.codeSystem).toBe("ICD10");
|
|
expect(first.clinicalStatus).toBe("active");
|
|
expect(first.onsetDate).toBe("2025-12-01");
|
|
|
|
const second = result.facts[1]!.data as ConditionFactData;
|
|
expect(second.code).toBeNull();
|
|
expect(second.codeSystem).toBeNull();
|
|
});
|
|
|
|
it("never sets both value and valueText on an Observation and never range-flags", async () => {
|
|
const provider = fakeProvider(
|
|
JSON.stringify({
|
|
reportDate: null,
|
|
facts: [
|
|
{
|
|
type: "OBSERVATION",
|
|
label: "Glucose",
|
|
code: "2345-7",
|
|
codeSystem: "LOINC",
|
|
value: 95,
|
|
valueText: "normal", // model sent both → numeric wins, text dropped
|
|
unit: "mg/dL",
|
|
referenceLow: 70,
|
|
referenceHigh: 100,
|
|
effectiveDate: "2026-01-02",
|
|
confidence: 0.95,
|
|
sourceText: "Glucose 95 mg/dL (70-100)",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const result = await runInboundExtraction({
|
|
provider,
|
|
providerType: "mock",
|
|
});
|
|
const obs = result.facts[0]!.data as ObservationFactData;
|
|
expect(obs.value).toBe(95);
|
|
expect(obs.valueText).toBeNull();
|
|
expect(obs.code).toBe("2345-7");
|
|
// Reference bounds ride along as stated — but no flag/severity is computed.
|
|
expect(obs.referenceLow).toBe(70);
|
|
expect(obs.referenceHigh).toBe(100);
|
|
expect(obs).not.toHaveProperty("rangeStatus");
|
|
expect(obs).not.toHaveProperty("flag");
|
|
});
|
|
|
|
it("routes a stated RxNorm code onto the medication and gates low confidence", async () => {
|
|
const provider = fakeProvider(
|
|
JSON.stringify({
|
|
facts: [
|
|
{
|
|
type: "MEDICATION_STATEMENT",
|
|
label: "Metformin",
|
|
code: "6809",
|
|
codeSystem: "RXNORM",
|
|
dose: "500 mg",
|
|
medicationStatus: "ongoing",
|
|
confidence: 0.3, // below the floor → needsReview
|
|
sourceText: "Metformin 500 mg",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const result = await runInboundExtraction({
|
|
provider,
|
|
providerType: "mock",
|
|
});
|
|
const fact = result.facts[0]!;
|
|
expect(fact.needsReview).toBe(true);
|
|
const med = fact.data as MedicationStatementFactData;
|
|
expect(med.rxNormCode).toBe("6809");
|
|
expect(med.atcCode).toBeNull();
|
|
expect(med.dose).toBe("500 mg");
|
|
expect(med.statusStated).toBe("ongoing");
|
|
});
|
|
|
|
it("stores the document's real span, not the model's echo of it", async () => {
|
|
// The OCR text says "Hämoglobin 14.2 g/dL"; the model echoes a tidied
|
|
// rendering of it. The stored provenance must be the document's characters.
|
|
const ocrText = "Labor 2026-01-02\nHämoglobin 14.2 g/dL (13.5-17.5)";
|
|
const provider = fakeProvider(
|
|
JSON.stringify({
|
|
facts: [
|
|
{
|
|
type: "OBSERVATION",
|
|
label: "Hämoglobin",
|
|
value: 14.2,
|
|
unit: "g/dL",
|
|
confidence: 0.9,
|
|
sourceText: "Hämoglobin 14.2 g/dL (13.5-17.5)",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const result = await runInboundExtraction({
|
|
provider,
|
|
providerType: "mock",
|
|
ocrText,
|
|
});
|
|
const { provenance } = result.facts[0]!;
|
|
expect(provenance.anchored).toBe(true);
|
|
expect(provenance.sourceText).toBe("Hämoglobin 14.2 g/dL (13.5-17.5)");
|
|
expect(
|
|
ocrText.slice(
|
|
provenance.sourceOffset!,
|
|
provenance.sourceOffset! + provenance.sourceText.length,
|
|
),
|
|
).toBe(provenance.sourceText);
|
|
});
|
|
|
|
it("marks a fact unanchored rather than storing a quote it cannot locate", async () => {
|
|
const ocrText = "Labor 2026-01-02\nHämoglobin 14.2 g/dL";
|
|
const provider = fakeProvider(
|
|
JSON.stringify({
|
|
facts: [
|
|
{
|
|
type: "OBSERVATION",
|
|
label: "Hämoglobin",
|
|
value: 14.2,
|
|
unit: "g/dL",
|
|
confidence: 0.9,
|
|
// A quote the document never carried.
|
|
sourceText: "Haemoglobin was measured at 14.2 and is normal",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const result = await runInboundExtraction({
|
|
provider,
|
|
providerType: "mock",
|
|
ocrText,
|
|
});
|
|
const { provenance } = result.facts[0]!;
|
|
expect(provenance.anchored).toBe(false);
|
|
expect(provenance.sourceText).toBe("");
|
|
expect(provenance.sourceOffset).toBeNull();
|
|
// The fact itself still stages — only its provenance claim is withheld.
|
|
expect(result.facts).toHaveLength(1);
|
|
});
|
|
|
|
it("marks vision-mode facts unanchored — there is no extracted text to verify", async () => {
|
|
const provider = fakeProvider(
|
|
JSON.stringify({
|
|
facts: [
|
|
{
|
|
type: "CONDITION",
|
|
label: "Type 2 diabetes mellitus",
|
|
confidence: 0.9,
|
|
sourceText: "Dx: Type 2 diabetes mellitus",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
|
|
const result = await runInboundExtraction({
|
|
provider,
|
|
providerType: "mock",
|
|
});
|
|
const { provenance } = result.facts[0]!;
|
|
expect(provenance.anchored).toBe(false);
|
|
expect(provenance.sourceText).toBe("");
|
|
});
|
|
|
|
it("throws InboundExtractError on unparseable provider output", async () => {
|
|
const provider = fakeProvider("not json at all");
|
|
await expect(
|
|
runInboundExtraction({ provider, providerType: "mock" }),
|
|
).rejects.toBeInstanceOf(InboundExtractError);
|
|
});
|
|
});
|