// 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) } });
}