import type { CoverageReview } from "./coverage";
import { z } from "zod";
import type { Draft, Fact, Review, Scenario, Source } from "./types";
import { conflicts } from "./review";
export const WORKSPACE_KEY = "marmar-context-workspace-v1";
const sourceSchema = z.object({
id: z
.string()
.regex(/^[a-z0-9-]+$/)
.max(60),
title: z.string().min(1).max(120),
kind: z.string().max(60),
date: z.string().max(30),
text: z.string().min(1).max(12000),
clarification: z
.object({
sourceId: z.string(),
line: z.number().int().positive(),
quote: z.string(),
reason: z.string(),
confirmedBy: z.string(),
})
.optional(),
});
const factSchema = z.object({
id: z.string(),
kind: z.enum([
"patient",
"age",
"medicine",
"allergy",
"condition",
"lab",
"weight",
"sex",
"pregnancy",
]),
label: z.string(),
detail: z.string(),
sourceId: z.string(),
quote: z.string(),
normalizedLine: z.string().optional(),
line: z.number().int().positive(),
key: z.string(),
problem: z.string().nullable(),
});
const draftSchema = z
.object({
id: z.string().optional(),
facts: z.array(factSchema),
unparsed: z.array(
z.object({ sourceId: z.string(), line: z.number(), text: z.string() })
),
mode: z.enum(["local", "model"]),
model: z.string().nullable(),
durationMs: z.number(),
fingerprint: z.string(),
sourceReviewRequired: z.boolean().optional(),
warnings: z
.array(
z.object({
sourceId: z.string(),
line: z.number(),
reason: z.string(),
normalizedLine: z.string().optional(),
blocked: z.boolean().optional(),
})
)
.optional(),
})
.passthrough();
const workspaceSchema = z.object({
version: z.literal(1),
savedAt: z.string(),
scenario: z.object({
id: z.string(),
name: z.string(),
initials: z.string(),
subtitle: z.string(),
description: z.string(),
focus: z.string(),
sources: z.array(sourceSchema).min(1).max(8),
}),
sources: z.array(sourceSchema).min(1).max(8),
sourceId: z.string(),
draft: draftSchema.nullable(),
review: z.record(z.string(), z.enum(["include", "exclude"])),
coverageReview: z.record(z.string(), z.object({fingerprint: z.string(), reason: z.string().max(500), confirmedBy: z.string().max(120)})).optional(),
useModel: z.boolean(),
editing: z.boolean(),
editText: z.string().max(12000),
report: z.record(z.string(), z.unknown()).nullable(),
});
export type Workspace = {
version: 1;
savedAt: string;
scenario: Scenario;
sources: Source[];
sourceId: string;
draft: Draft | null;
review: Review;
coverageReview?: CoverageReview;
useModel: boolean;
editing: boolean;
editText: string;
report: Record<string, unknown> | null;
};
export function readWorkspace(
storage: Pick<Storage, "getItem">
): Workspace | null {
const raw = storage.getItem(WORKSPACE_KEY);
if (!raw) return null;
return parseWorkspace(raw);
}
export function parseWorkspace(raw: string): Workspace {
if (raw.length > 2000000) throw new Error("Workspace backup is too large.");
const result = workspaceSchema.safeParse(JSON.parse(raw));
if (!result.success)
throw new Error(
"Saved workspace could not be read. The saved copy has not been overwritten."
);
return result.data as Workspace;
}
export function writeWorkspace(
storage: Pick<Storage, "setItem">,
workspace: Workspace
) {
storage.setItem(WORKSPACE_KEY, JSON.stringify(workspace));
}
function identity(f: Fact) {
return JSON.stringify([
f.sourceId,
f.line,
f.quote,
f.kind,
f.key,
f.label,
f.detail,
f.normalizedLine ?? null,
f.problem,
]);
}
// Never transfer a decision to a changed, ambiguous or newly extracted fact.
export function recoverReview(
previous: Draft | null,
next: Draft,
review: Review
): Review {
if (!previous || previous.fingerprint !== next.fingerprint) return {};
const decisions = new Map<string, Set<string>>();
for (const fact of previous.facts)
if (review[fact.id]) {
const key = identity(fact);
const values = decisions.get(key) ?? new Set();
values.add(review[fact.id]);
decisions.set(key, values);
}
return Object.fromEntries(
next.facts.flatMap((f) => {
const values = decisions.get(identity(f));
return values?.size === 1 ? [[f.id, [...values][0]]] : [];
})
) as Review;
}
export function linkedClarification(
sources: Source[],
sourceId: string,
line: number
) {
return sources.find(
(s) =>
s.clarification?.sourceId === sourceId && s.clarification.line === line
);
}
export function clarificationReviewed(
sources: Source[],
draft: Draft | null,
review: Review,
sourceId: string,
line: number
) {
const linked = linkedClarification(sources, sourceId, line);
if (!linked || !draft) return false;
const facts = draft.facts.filter((f) => f.sourceId === linked.id);
const included = draft.facts.filter((f) => review[f.id] === "include");
const conflictKeys = conflicts(included);
return (
facts.some(
(f) =>
f.kind !== "patient" &&
f.kind !== "age" &&
!f.problem &&
review[f.id] === "include"
) &&
facts.every(
(f) =>
!!review[f.id] &&
!(
review[f.id] === "include" &&
(f.problem || conflictKeys.includes(f.key))
)
) &&
!draft.unparsed.some((l) => l.sourceId === linked.id) &&
!draft.warnings?.some((w) => w.sourceId === linked.id) &&
!draft.facts.some(
(f) =>
f.sourceId === sourceId &&
f.line === line &&
f.problem &&
review[f.id] !== "exclude"
)
);
}
export function actionableWarnings(draft: Draft | null) {
return (draft?.warnings ?? []).filter(
(w) =>
!(
w.reason ===
"A partial name cannot replace the patient identity already recorded in this source." &&
draft?.facts.some(
(f) => f.sourceId === w.sourceId && f.kind === "patient" && !f.problem
)
)
);
}
export function clarificationError(text: string, basis: string) {
if (!text.trim()) return "Enter the confirmed patient statement.";
if (text.trim().length > 12000)
return "The confirmed statement must be 12,000 characters or fewer.";
if (!basis.trim())
return "Enter who confirmed this detail or the verification basis.";
if (basis.trim().length > 120)
return "Confirmed by / basis must be 120 characters or fewer. Shorten it before saving.";
return "";
}