marmar-context / web / context / src / workspace.ts
workspace.ts
Raw
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 "";
}