marmar-context / web / context / test / fixture / calls.ts
calls.ts
Raw
// Simulated Yasmar check-in calls for the fixture. Notes stream in over about a minute,
// as the film shows. Words and people are fictional.
import type * as T from "../../src/api/types";
import { applyEvent } from "../../src/logic/checkin-events";

export type Variant = "film" | "urgent" | "declined" | "later" | "identity" | "missed";
type NewEvent = T.CheckInEvent extends infer E ? (E extends T.CheckInEvent ? Omit<E, "id"> : never) : never;

export type CallRecord = {
  snapshot: T.CheckIn;
  events: T.CheckInEvent[];
  listeners: Set<(event: T.CheckInEvent) => void>;
  readAt: string | null;
  timers: ReturnType<typeof setTimeout>[];
  linkedTiers: string[];
};

export function newCall(snapshot: T.CheckIn, linkedTiers: string[]): CallRecord {
  return { snapshot, events: [], listeners: new Set(), readAt: null, timers: [], linkedTiers };
}

export function emit(rec: CallRecord, event: NewEvent): T.CheckInEvent {
  const full = { ...event, id: rec.snapshot.lastEventId + 1 } as T.CheckInEvent;
  rec.events.push(full);
  rec.snapshot = applyEvent(rec.snapshot, full);
  for (const listener of rec.listeners) listener(full);
  return full;
}

type Step =
  | { at: number; kind: "line"; speaker: "yasmar" | "patient"; text: string; id: string }
  | { at: number; kind: "note"; note: Omit<T.CheckInNote, "id" | "at" | "acknowledgedAt" | "acknowledgedBy" | "disputed" | "addedToCheckAt" | "validation" | "endedEarly"> }
  | { at: number; kind: "status"; status: T.CheckInStatus; detail: string | null; later?: { start: string; end: string } }
  | { at: number; kind: "summary"; sentences: { text: string; noteTexts: string[] }[] };

type Answer = { says: string[]; note?: { kind: T.NoteKind; text: string } };
/** Mariam's answers, from the film. */
const MARIAM: Record<string, Answer> = {
  "medicine-bought": { says: ["Not yet. I haven't bought it."], note: { kind: "not_started", text: "Amoxicillin not bought or started" } },
  "medicine-started": { says: ["No, I haven't started it."], note: { kind: "not_started", text: "Amoxicillin not bought or started" } },
  "medicine-pack": { says: ["I don't have the pack yet."] },
  "allergy-history": { says: ["Ah, penicillin gave me itchy rashes all over."], note: { kind: "allergy_history", text: "Past penicillin: itchy rash all over" } },
  "other-medicines": { says: ["Bitter leaf water for my sugar. And agbo, sometimes."], note: { kind: "other_medicines", text: "Takes bitter leaf water and agbo" } },
  "missed-doses": { says: ["Sometimes the evening one, when my stomach is bad."], note: { kind: "how_taken", text: "Misses evening Metformin when her stomach is bad" } },
  weight: { says: ["I don't know my weight."], note: { kind: "measurement", text: "Weight not known" } },
  "kidney-test": { says: ["Only the one at Yaba, this month."], note: { kind: "measurement", text: "Last kidney test: Yaba lab, this month" } },
  "metformin-stomach": { says: ["Yes, the stomach running I told you."], note: { kind: "side_effect", text: "Loose stools while on Metformin" } },
  "potassium-symptoms": { says: ["No, nothing like that."], note: { kind: "symptom", text: "No muscle weakness or fast heartbeat" } },
};

const speak = (text: string, speaker: "yasmar" | "patient") => Math.max(1.4, text.length / (speaker === "yasmar" ? 30 : 18));

/** Build the call from the plan, so Yasmar asks the clinician's questions in their order. */
export function script(input: {
  variant: Variant;
  patient: { name: string; firstName: string; dateOfBirthSpoken: string; preferredTitle: string | null };
  plan: T.CheckInPlanTemplate;
  questions: T.PlanQuestion[];
  closings: string[];
  emergency: string;
  clinicPhone: string;
  doctor: string;
}): Step[] {
  const { variant, patient, plan } = input;
  const steps: Step[] = [];
  let t = 0;
  let n = 0;
  const say = (speaker: "yasmar" | "patient", text: string) => {
    const id = `line-${++n}`;
    steps.push({ at: t, kind: "line", speaker, text, id });
    t += speak(text, speaker) + 0.5;
    return id;
  };
  const note = (kind: T.NoteKind, text: string, quote: string, lines: string[], extra: Partial<T.CheckInNote> = {}) =>
    steps.push({ at: t - 0.2, kind: "note", note: { kind, text, quote, questionId: null, transcriptLineIds: lines, urgent: false, ...extra } });
  const status = (s: T.CheckInStatus, detail: string | null = null, later?: { start: string; end: string }) =>
    steps.push({ at: t, kind: "status", status: s, detail, later });

  status("sent");
  t += 2;
  status("ringing");
  if (variant === "declined") {
    t += 6;
    status("declined", `${patient.firstName} declined.`);
    return steps;
  }
  if (variant === "missed") {
    t += 12;
    status("missed", "Nobody answered. A notification lets her start it any time inside the window.");
    return steps;
  }
  if (variant === "later") {
    t += 5;
    const start = new Date(Date.now() + 4 * 3_600_000);
    status("later", `${patient.firstName} chose a later time.`, { start: start.toISOString(), end: new Date(start.getTime() + 2 * 3_600_000).toISOString() });
    return steps;
  }
  t += 4;
  status("in_progress");
  for (const line of plan.opening) say("yasmar", line);
  say("yasmar", plan.identity[0]);
  say("patient", "Yes, it's me.");
  say("yasmar", plan.identity[1]);
  if (variant === "identity") {
    say("patient", "The second of May, nineteen sixty.");
    say("yasmar", "I'm sorry, that doesn't match. Please tell me your date of birth again.");
    say("patient", "Ah, maybe nineteen sixty-one.");
    say("yasmar", "I'm sorry, I can't go on with this check-in. I'll let the clinic know.");
    status("identity_not_confirmed", "Two tries did not match. Nothing clinical was said.");
    return steps;
  }
  say("patient", input.patient.dateOfBirthSpoken);
  say("yasmar", `Thank you${patient.preferredTitle ? `, ${patient.preferredTitle}` : ""}. ${plan.consent}`);
  say("patient", "Yes, go on.");
  const feelingQ = say("yasmar", plan.feeling);
  if (variant === "urgent") {
    const a = say("patient", "Not well. My heart is beating fast and my chest feels tight since this morning.");
    note("red_flag", "Chest tightness and a fast heartbeat since this morning", "My heart is beating fast and my chest feels tight since this morning.", [a], { urgent: true });
    say("yasmar", `That could be serious. Please call ${input.emergency} now, or go to the nearest hospital. Is someone with you?`);
    const b = say("patient", "My son is here with me.");
    note("symptom", "Her son is with her", "My son is here with me.", [b]);
    say("yasmar", `Thank you. I've sent this to ${input.doctor}'s clinic as urgent. Please call them today on ${input.clinicPhone}. If it gets worse, call ${input.emergency} now. I'll end our check-in here so you can get help.`);
    status("completed", "Ended after a red flag. Yasmar gave the emergency line.");
    t += 3;
    steps.push({
      at: t,
      kind: "summary",
      sentences: [
        { text: "Urgent: chest tightness and a fast heartbeat since this morning; Yasmar gave the emergency line.", noteTexts: ["Chest tightness and a fast heartbeat since this morning"] },
        { text: "Her son is with her.", noteTexts: ["Her son is with her"] },
      ],
    });
    return steps;
  }
  const f1 = say("patient", "Managing o. Tired, and my stomach is running.");
  say("yasmar", "Since when? And any vomiting?");
  const f2 = say("patient", "About a week. Three, four times a day. No vomiting. When it's bad, I skip my evening Metformin.");
  note("symptom", "Tired, with loose stools 3 to 4 times a day for about 1 week", "Tired, and my stomach is running. About a week. Three, four times a day.", [f1, f2]);
  note("symptom", "No vomiting reported", "No vomiting.", [f2]);
  note("how_taken", "Skips evening Metformin when unwell", "When it's bad, I skip my evening Metformin.", [f2]);
  void feelingQ;
  const noted = new Set<string>();
  for (const q of input.questions) {
    say("yasmar", q.text);
    const answer = (q.bankId && MARIAM[q.bankId]) || { says: ["I'm not sure."], note: { kind: "symptom" as const, text: `Not sure: "${q.text}"` } };
    const ids = answer.says.map((s) => say("patient", s));
    if (answer.note && !noted.has(answer.note.text)) {
      noted.add(answer.note.text);
      note(answer.note.kind, answer.note.text, answer.says.join(" "), ids, { questionId: q.id });
    }
  }
  say("yasmar", plan.alwaysLast);
  say("patient", "No, that's all.");
  const chosen = plan.closings.filter((c) => c.fixed || input.closings.includes(c.id)).map((c) => c.text);
  say("yasmar", [...chosen, plan.safetyNet].join(" "));
  say("patient", "Thank you, my dear.");
  status("completed");
  t += 3;
  steps.push({ at: t, kind: "summary", sentences: [] });
  return steps;
}

/** YCI-25: two to four sentences built only from the notes, each linked to its notes. */
export function summarise(notes: T.CheckInNote[]): T.SummarySentence[] {
  const urgent = notes.filter((n) => n.urgent);
  const feeling = notes.filter((n) => !n.urgent && !n.questionId);
  const rest = notes.filter((n) => !n.urgent && n.questionId);
  const sentences: T.SummarySentence[] = [];
  if (urgent.length) sentences.push({ text: `Urgent: ${urgent.map((n) => n.text.toLowerCase()).join("; ")}.`, noteIds: urgent.map((n) => n.id) });
  if (feeling.length)
    sentences.push({
      text: `${feeling.map((n, i) => (i ? n.text.charAt(0).toLowerCase() + n.text.slice(1) : n.text)).join("; ")}.`,
      noteIds: feeling.map((n) => n.id),
    });
  const room = Math.max(1, 4 - sentences.length);
  const size = Math.ceil(rest.length / room) || 1;
  for (let i = 0; i < rest.length; i += size) {
    const group = rest.slice(i, i + size);
    sentences.push({ text: `${group.map((n, j) => (j ? n.text.charAt(0).toLowerCase() + n.text.slice(1) : n.text)).join("; ")}.`, noteIds: group.map((n) => n.id) });
  }
  return sentences;
}

/** Play a script in real time (scaled by `speed`), emitting events as the call goes. */
export function play(rec: CallRecord, steps: Step[], speed: number, startAt = Date.now()): void {
  const total = Math.max(...steps.map((s) => s.at));
  // Keep the whole call to about a minute, as in the film.
  const scale = (total > 62 ? 62 / total : 1) / speed;
  // Offsets into the call count from when Yasmar starts talking, not from the request.
  const callStart = steps.find((s) => s.kind === "status" && s.status === "in_progress")?.at ?? 0;
  let noteCount = 0;
  for (const step of steps) {
    const delay = Math.max(0, startAt + step.at * scale * 1000 - Date.now());
    rec.timers.push(
      setTimeout(() => {
        const at = new Date().toISOString();
        const offset = Math.round(Math.max(0, step.at - callStart) * scale * speed * 10) / 10;
        if (step.kind === "line")
          emit(rec, { type: "transcript", at, line: { id: step.id, speaker: step.speaker, text: step.text, offsetSeconds: offset, at } });
        else if (step.kind === "status") emit(rec, { type: "status", at, status: step.status, detail: step.detail, later: step.later ?? null });
        else if (step.kind === "note") {
          const note: T.CheckInNote = { ...step.note, id: `${rec.snapshot.id}-n${++noteCount}`, at, acknowledgedAt: null, acknowledgedBy: null, disputed: false, addedToCheckAt: null, validation: "matched", endedEarly: false };
          emit(rec, { type: "note", at, note });
          if (note.urgent) {
            emit(rec, { type: "urgent", at, noteId: note.id, reason: note.text });
            // As the backend does once the urgent text and email go out (YCI-29); the fixture sends nothing.
            emit(rec, { type: "alert", at, kind: "urgent_sent", message: "Sent by email" });
          }
        } else {
          const notes = rec.snapshot.notes;
          const sentences = step.sentences.length
            ? step.sentences.map((s) => ({ text: s.text, noteIds: notes.filter((n) => s.noteTexts.includes(n.text)).map((n) => n.id) }))
            : summarise(notes);
          emit(rec, { type: "summary", at, summary: { sentences, createdAt: at } });
        }
      }, delay),
    );
  }
}

/** A finished call, for patients seeded with one. Events are stamped in the past. */
export function finishedCall(
  rec: CallRecord,
  startedAt: number,
  lines: { speaker: "yasmar" | "patient"; text: string }[],
  notes: { kind: T.NoteKind; text: string; quote: string; line: number; urgent?: boolean }[],
): void {
  const stamp = (s: number) => new Date(startedAt + s * 1000).toISOString();
  emit(rec, { type: "status", at: stamp(2), status: "ringing", detail: null, later: null });
  emit(rec, { type: "status", at: stamp(8), status: "in_progress", detail: null, later: null });
  lines.forEach((l, i) => emit(rec, { type: "transcript", at: stamp(10 + i * 5), line: { id: `line-${i + 1}`, speaker: l.speaker, text: l.text, offsetSeconds: i * 5, at: stamp(10 + i * 5) } }));
  notes.forEach((n, i) =>
    emit(rec, {
      type: "note",
      at: stamp(12 + n.line * 5),
      note: { id: `${rec.snapshot.id}-n${i + 1}`, kind: n.kind, text: n.text, quote: n.quote, questionId: null, transcriptLineIds: [`line-${n.line + 1}`], at: stamp(12 + n.line * 5), urgent: !!n.urgent, acknowledgedAt: null, acknowledgedBy: null, disputed: false, addedToCheckAt: null, validation: "matched", endedEarly: false },
    }),
  );
  const end = 12 + lines.length * 5;
  emit(rec, { type: "status", at: stamp(end), status: "completed", detail: null, later: null });
  emit(rec, { type: "summary", at: stamp(end + 3), summary: { sentences: summarise(rec.snapshot.notes), createdAt: stamp(end + 3) } });
}