marmar-context / web / context / src / clinician / check / ReviewStep.tsx
ReviewStep.tsx
Raw
import { useState } from "react";
import { clinicianApi } from "../../api/client";
import type { Extraction, PatientDetail } from "../../api/types";
import { unresolvedCoverage, type CoverageCandidate, type CoverageReview } from "../../coverage";
import { checkBlocker } from "../../flow";
import { factDetail } from "../../logic/format";
import { conflicts } from "../../review";
import type { Fact, Review } from "../../types";
import { Button, DialogActions, FieldError, Icon, type IconName, Input, labelClass, Modal, Panel, Select, StatusPill } from "../kit";
import { displayName, useClinician } from "../session";
import { SourceDrawer, type SourceRef } from "../patient/SourceDrawer";
import { errorText } from "../ui";

const order: Fact["kind"][] = ["patient", "age", "sex", "weight", "pregnancy", "medicine", "allergy", "lab", "condition"];
const groupNames: Record<Fact["kind"], string> = {
  patient: "Patient",
  age: "Age",
  sex: "Sex",
  weight: "Weight",
  pregnancy: "Pregnancy",
  medicine: "Medicines",
  allergy: "Allergies",
  lab: "Lab results",
  condition: "Conditions",
};
const factIcons: Record<string, IconName> = { medicine: "capsule-line", allergy: "error-warning-line", lab: "flask-line", weight: "scales-3-line", pregnancy: "women-line", condition: "heart-pulse-line" };
export function FactIcon({ kind }: { kind: string }) {
  return <Icon name={factIcons[kind] ?? "user-line"} size={16} />;
}

type Props = {
  patient: PatientDetail;
  extraction: Extraction;
  review: Review;
  coverage: CoverageReview;
  acknowledged: boolean;
  /** Included Hard to read facts the clinician checked one by one (REC-5). */
  confirmed: string[];
  busy: boolean;
  onConfirm: (ids: string[]) => void;
  onReview: (r: Review) => void;
  onCoverage: (c: CoverageReview) => void;
  onAcknowledge: (v: boolean) => void;
  onClarified: (ex: Extraction) => void;
  onRun: () => void;
};

/** REV-1 to REV-5: every fact with its source line; include or exclude each; the omission block. */
const kindNames: Record<string, string> = { medicine: "Medicine", allergy: "Allergy", kidney: "Kidney result" };

export function ReviewStep({ extraction, review, coverage, acknowledged, confirmed, busy, onConfirm, onReview, onCoverage, onAcknowledge, onClarified, onRun }: Props) {
  const { me } = useClinician();
  const [source, setSource] = useState<SourceRef | null>(null);
  const [filter, setFilter] = useState<"all" | "pending" | "excluded">("all");
  const [adding, setAdding] = useState<CoverageCandidate | null>(null);
  const [reasons, setReasons] = useState<Record<string, string>>({});
  const title = (id: string) => recordTitle(extraction, id);
  const selected = extraction.facts.filter((f) => review[f.id] === "include");
  const conflictKeys = conflicts(selected);
  const unconfirmed = extraction.facts.filter((f) => f.hardToRead && review[f.id] === "include" && !confirmed.includes(f.id));
  const blocker =
    checkBlocker({ draft: extraction, review, acknowledged, coverageReview: coverage, editing: false }) ??
    (unconfirmed.length ? `Check ${unconfirmed.length === 1 ? "the Hard to read value" : `${unconfirmed.length} Hard to read values`} against the original before the check.` : null);
  const unresolved = unresolvedCoverage(extraction.coverage ?? [], coverage, extraction.fingerprint);
  const decided = extraction.facts.filter((f) => review[f.id]).length;
  const visible = extraction.facts.filter((f) => (filter === "pending" ? !review[f.id] : filter === "excluded" ? review[f.id] === "exclude" : true));
  const decide = (id: string, d: "include" | "exclude") => {
    if (d === "exclude" && confirmed.includes(id)) onConfirm(confirmed.filter((x) => x !== id));
    onReview({ ...review, [id]: d });
  };
  const toggleConfirmed = (id: string, on: boolean) => onConfirm(on ? [...new Set([...confirmed, id])] : confirmed.filter((x) => x !== id));
  // Excluding an allergy needs the clinician's own reason (10 characters or more); the others take the choice alone.
  const resolve = (c: CoverageCandidate, choice: "not_current" | "not_relevant") => {
    const own = (reasons[c.id] ?? "").trim();
    if (c.kind === "allergy" && own.length < 10) return;
    onCoverage({
      ...coverage,
      [c.id]: {
        fingerprint: extraction.fingerprint,
        choice,
        reason: own || (choice === "not_current" ? "Not current: marked by the clinician" : "Not relevant: recorded, not checked"),
        confirmedBy: displayName(me),
      },
    });
  };

  return (
    <div className="review-step">
      <div className="review-head">
        <p className="answer-line">
          <strong>
            {decided} of {extraction.facts.length} decided
          </strong>{" "}
          · Nothing goes into the check until you have reviewed it.
        </p>
        {extraction.mode === "model" && extraction.model && (
          <p className="m-0 flex flex-wrap items-center gap-2 text-[12.5px] text-muted">
            <StatusPill tone="blue" icon="sparkling-fill" size="sm">
              Read by {extraction.model.startsWith("muse-") ? `Meta Muse Spark (${extraction.model})` : extraction.model}
            </StatusPill>
            Every fact quotes its source line; anything that doesn't is dropped.
          </p>
        )}
        <div className="row-wrap">
          <div className="segmented" role="group" aria-label="Show facts">
            {(["all", "pending", "excluded"] as const).map((f) => (
              <button key={f} aria-pressed={filter === f} onClick={() => setFilter(f)}>
                {f === "all" ? "All" : f === "pending" ? "Waiting" : "Excluded"}
              </button>
            ))}
          </div>
          <button
            className="button"
            disabled={busy}
            onClick={() => onReview({ ...review, ...Object.fromEntries(extraction.facts.filter((f) => !f.problem && !f.hardToRead && !review[f.id]).map((f) => [f.id, "include" as const])) })}
          >
            Include all supported
          </button>
        </div>
      </div>
      {order
        .filter((kind) => visible.some((f) => f.kind === kind))
        .map((kind) => (
          <Panel key={kind} title={groupNames[kind]} icon={<FactIcon kind={kind} />} flush>
            {visible
              .filter((f) => f.kind === kind)
              .map((f) => {
                const d = review[f.id];
                const sourceTitle = title(f.sourceId);
                return (
                  <div key={f.id} className={`fact ${d ?? "pending"} ${conflictKeys.includes(f.key) && d === "include" ? "conflict" : ""} ${f.problem ? "problem" : ""}`}>
                    <span className={`fact-icon ${f.kind}`} aria-hidden="true">
                      <FactIcon kind={f.kind} />
                    </span>
                    <div className="fact-body">
                      <div className="fact-title">
                        <strong>{f.label}</strong>
                        {f.detail && <span>{factDetail(f.detail)}</span>}
                      </div>
                      {f.problem && (
                        <p className="fact-problem">
                          <Icon name="error-warning-line" size={14} /> {f.problem}
                        </p>
                      )}
                      {f.hardToRead && (
                        <label className="check hard-to-read">
                          <input type="checkbox" checked={confirmed.includes(f.id)} disabled={busy || d !== "include"} onChange={(e) => toggleConfirmed(f.id, e.target.checked)} />
                          <span>
                            <b>Hard to read.</b> {d === "include" ? "I checked this value against the original." : "Include it, then check the value against the original."}
                          </span>
                        </label>
                      )}
                      <div className="fact-links">
                        <button className="link quiet" onClick={() => setSource({ recordId: f.sourceId, title: sourceTitle, lines: [f.line] })}>
                          <Icon name="file-text-line" size={13} /> {sourceTitle} · line {f.line}
                        </button>
                      </div>
                    </div>
                    <div className="decision" role="group" aria-label={`Decision: ${f.label}`}>
                      <button aria-label={`Include ${f.label} from ${sourceTitle}`} aria-pressed={d === "include"} disabled={!!f.problem || busy} onClick={() => decide(f.id, "include")}>
                        <Icon name="check-line" size={15} />
                        <span>Include</span>
                      </button>
                      <button aria-label={`Exclude ${f.label} from ${sourceTitle}`} aria-pressed={d === "exclude"} disabled={busy} onClick={() => decide(f.id, "exclude")}>
                        <Icon name="close-line" size={15} />
                        <span>Exclude</span>
                      </button>
                    </div>
                  </div>
                );
              })}
          </Panel>
        ))}
      {unresolved.length > 0 || Object.keys(coverage).length > 0 ? (
        <Panel title="Mentions the reader did not turn into facts" icon="capsule-line">
          <p className="hint">Each mention must be added, marked not current, or marked not relevant before the check runs.</p>
          <ul className="coverage-list">
            {(extraction.coverage ?? [])
              .filter((c) => !c.matchedFactIds.length && !c.contextExclusion)
              .map((c) => {
                const done = coverage[c.id];
                const allergy = c.kind === "allergy";
                const reasonShort = allergy && (reasons[c.id] ?? "").trim().length < 10;
                return (
                  <li key={c.id}>
                    <div>
                      <small className="muted">{kindNames[c.kind ?? "medicine"]}</small> <b>{c.text}</b>
                      <button className="link quiet" onClick={() => setSource({ recordId: c.sourceId, title: title(c.sourceId), lines: [c.line] })}>
                        <q>{c.quote}</q>
                      </button>
                      {done && <small className="muted">{done.reason} · {done.confirmedBy}</small>}
                      {allergy && !done && (
                        <label className="stack">
                          <small>Why is this allergy not current or not relevant? (10 characters or more)</small>
                          <input value={reasons[c.id] ?? ""} disabled={busy} onChange={(e) => setReasons({ ...reasons, [c.id]: e.target.value })} />
                        </label>
                      )}
                    </div>
                    <div className="row-wrap">
                      {(c.kind ?? "medicine") === "medicine" && (
                        <button className="button" disabled={busy} onClick={() => setAdding(c)}>
                          Add it
                        </button>
                      )}
                      <button className="button" aria-pressed={done?.choice === "not_current" || done?.reason.startsWith("Not current")} disabled={busy || reasonShort} onClick={() => resolve(c, "not_current")}>
                        Not current
                      </button>
                      <button className="button" aria-pressed={done?.choice === "not_relevant" || done?.reason.startsWith("Not relevant")} disabled={busy || reasonShort} onClick={() => resolve(c, "not_relevant")}>
                        Not relevant
                      </button>
                    </div>
                  </li>
                );
              })}
          </ul>
        </Panel>
      ) : null}
      {(extraction.unparsed.length > 0 || extraction.sourceReviewRequired) && (
        <Panel title="Lines not turned into facts" icon="file-text-line" note={extraction.unparsed.length === 1 ? "1 line" : `${extraction.unparsed.length} lines`}>
          <ul className="unparsed">
            {extraction.unparsed.map((u) => (
              <li key={`${u.sourceId}:${u.line}`}>
                <button className="link quiet" onClick={() => setSource({ recordId: u.sourceId, title: title(u.sourceId), lines: [u.line] })}>
                  {title(u.sourceId)} · line {u.line}
                </button>
                <q>{u.text}</q>
              </li>
            ))}
          </ul>
          <label className="check">
            <input type="checkbox" checked={acknowledged} disabled={busy} onChange={(e) => onAcknowledge(e.target.checked)} />
            <span>I read the original records for anything the reader missed. These lines are not checked by any rule.</span>
          </label>
        </Panel>
      )}
      <div className="actionbar">
        <div className="actionbar-inner">
          <span className={`hint ${blocker ? "blocked" : "ready"}`} role="status">
            {blocker ?? "Ready. The check runs twice: the list alone, then with what you confirmed."}
          </span>
          <Button variant="primary" disabled={!!blocker || busy} onClick={onRun}>
            Run the check
          </Button>
        </div>
      </div>
      <SourceDrawer source={source} onClose={() => setSource(null)} />
      <AddMedicine candidate={adding} extraction={extraction} onClose={() => setAdding(null)} onAdded={(ex) => { setAdding(null); onClarified(ex); }} />
    </div>
  );
}

export const recordTitle = (ex: Extraction, id: string) => ex.titles?.[id] ?? ex.records.find((r) => r.recordId === id)?.title ?? "Record";

/** REV-4 "Add it": the clinician states the medicine; it is linked to the unchanged original line. */
function AddMedicine({ candidate, extraction, onClose, onAdded }: { candidate: CoverageCandidate | null; extraction: Extraction; onClose: () => void; onAdded: (ex: Extraction) => void }) {
  const [form, setForm] = useState({ name: "", strength: "", amount: "", form: "tablet", perDay: "", reason: "" });
  const [error, setError] = useState("");
  const [forId, setForId] = useState<string | null>(null);
  if (candidate && forId !== candidate.id) {
    setForId(candidate.id);
    setForm({ name: candidate.text.replace(/^./, (c) => c.toUpperCase()), strength: "", amount: "", form: "tablet", perDay: "", reason: "" });
    setError("");
  }
  const save = async () => {
    if (!candidate) return;
    if (!form.name.trim()) return setError("Name the medicine.");
    if (form.reason.trim().length < 3) return setError("Say how you confirmed it, for example 'Confirmed with the patient today'.");
    const parts = [`Medication: ${form.name.trim()}`];
    if (form.strength.trim()) parts.push(`strength: ${form.strength.trim()}`);
    if (form.amount) parts.push(`form: ${form.form}`, `dose: ${form.amount} ${form.form}`);
    if (form.perDay) parts.push(`frequency: ${form.perDay}/day`);
    try {
      onAdded(
        await clinicianApi.addClarification(extraction.id, {
          recordId: candidate.sourceId,
          line: candidate.line,
          quote: candidate.quote,
          text: parts.join("; "),
          reason: form.reason.trim(),
        }),
      );
    } catch (e) {
      setError(errorText(e));
    }
  };
  return (
    <Modal open={!!candidate} title="Add a medicine the reader missed" onClose={onClose}>
      {candidate && (
        <p className="m-0">
          From <q>{candidate.quote}</q>. It stays linked to that line, with your name on it.
        </p>
      )}
      <label className={labelClass}>
        Medicine
        <Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
      </label>
      <div className="grid gap-3 sm:grid-cols-2">
        <label className={labelClass}>
          Strength (optional)
          <Input value={form.strength} placeholder="500 mg" onChange={(e) => setForm({ ...form, strength: e.target.value })} />
        </label>
        <label className={labelClass}>
          Times a day (optional)
          <Input inputMode="numeric" value={form.perDay} onChange={(e) => setForm({ ...form, perDay: e.target.value.replace(/[^\d.]/g, "") })} />
        </label>
        <label className={labelClass}>
          Amount each time (optional)
          <Input inputMode="numeric" value={form.amount} onChange={(e) => setForm({ ...form, amount: e.target.value.replace(/[^\d.]/g, "") })} />
        </label>
        <label className={labelClass}>
          Form
          <Select value={form.form} onChange={(e) => setForm({ ...form, form: e.target.value })}>
            <option value="tablet">Tablet</option>
            <option value="capsule">Capsule</option>
          </Select>
        </label>
      </div>
      <label className={labelClass}>
        How you confirmed it
        <Input value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })} />
      </label>
      {error && <FieldError>{error}</FieldError>}
      <DialogActions>
        <Button onClick={onClose}>Cancel</Button>
        <Button variant="primary" onClick={() => void save()}>
          Add and read again
        </Button>
      </DialogActions>
    </Modal>
  );
}