// 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 : never) : never; export type CallRecord = { snapshot: T.CheckIn; events: T.CheckInEvent[]; listeners: Set<(event: T.CheckInEvent) => void>; readAt: string | null; timers: ReturnType[]; 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 } | { 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 = { "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 = {}) => 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(); 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) } }); }