import type { DialogButton, Reply } from "../api/types"; /* * Maps a batch of engine replies to a focus-screen layout (docs/mock). Pure function: the engine decides WHAT is * asked, this only decides HOW it looks. Unknown shapes fall back to a plain choice / text screen. */ export type Step = | { kind: "idle"; notes: string[] } // engine is back at the menu: the flow is over | { kind: "ack"; text: string[] } // a short confirmation without a question: a toast, then home | { kind: "consent"; lead: string[]; title: string; buttons: DialogButton[] } | { kind: "crisis"; text: string; buttons: DialogButton[] } | { kind: "plan"; lead: string[]; title: string; lines: string[]; buttons: DialogButton[] } | { kind: "number"; lead: string[]; title: string; options: DialogButton[] } | { kind: "actions"; lead: string[]; title: string; options: DialogButton[] } | { kind: "choice"; lead: string[]; title: string; options: DialogButton[]; progress?: Progress; formId?: string } | { kind: "text"; lead: string[]; title: string; skip?: DialogButton } | Survey // A questionnaire stopped: paused by the patient (resume button) or held for a specialist (help button only). | { kind: "notice"; text: string[]; primary?: DialogButton; help?: DialogButton }; /** One item of a versioned questionnaire (app/dialog/flows/intake.py → question_reply). */ export interface Survey { kind: "survey"; itemId: string; instruction: string[]; // form instruction / recall period above the question title: string; // the item text, verbatim footnotes: string[]; // «*…» explanations printed under the item on the paper form options: DialogButton[]; input: "choice" | "number" | "hours" | "portions" | "text"; anchors?: string[]; // «0 — самое плохое», «10 — самое хорошее»: the scale ends as printed in the item asOf?: string; // ISO date the recall period counts from skip?: DialogButton; help?: DialogButton; pause?: DialogButton; } export interface Progress { n: number; total: number; } // config/texts/dialog.yaml → idle.menu_prompt: the bare menu carries no message of its own. const MENU_PROMPT = "Чем могу помочь?"; // config/texts/dialog.yaml → scale.question: "{n}/{total}. {text}" const SCALE_ITEM = /^(\d+)\/(\d+)\.\s+([\s\S]+)$/; // config/texts/intake.yaml → as_of: "Период отсчитывается от даты начала этого опроса: {day}." const AS_OF = /^Период отсчитывается[^:]*:\s*(\d{4}-\d{2}-\d{2})\.?$/; // config/texts/intake.yaml → standard_drinks: printed inside the RUS-AUDIT item 2 instruction; shown as a table. const DRINKS_START = "Стандартная порция"; const ANCHORS = /^\d+\s*—/; const IQ = /^iq:[^:]+:([^:]+):(\d+|skip)$/; const isMenu = (r: Reply) => r.buttons.flat().some((b) => b.data.startsWith("menu:")); const flat = (r: Reply) => r.buttons.flat(); const paragraphs = (text: string) => text .split(/\n\s*\n/) .map((p) => p.trim()) .filter(Boolean); /** Splits a reply into a title (the actual question) and lead paragraphs above it. */ function titled(texts: string[]): { lead: string[]; title: string; progress?: Progress } { const parts = texts.flatMap(paragraphs); const last = parts.pop() ?? ""; const m = SCALE_ITEM.exec(last); if (m) return { lead: parts, title: m[3].trim(), progress: { n: Number(m[1]), total: Number(m[2]) } }; // A long last paragraph is body text, not a heading. if (last.length > 220) return { lead: [...parts, last], title: "" }; return { lead: parts, title: last }; } function survey(batch: Reply[], qIndex: number): Survey | null { const buttons = flat(batch[qIndex]); const iq = buttons.map((b) => IQ.exec(b.data)).find(Boolean); const help = buttons.find((b) => b.data === "intake:help"); const pause = buttons.find((b) => b.data === "intake:pause"); if (!pause || !help) return null; // A free-text / numeric item has no answer buttons: the item id is then unknown, the text is still verbatim. const itemId = iq?.[1] ?? ""; const options = buttons.filter((b) => IQ.test(b.data) && !b.data.endsWith(":skip")); const skip = buttons.find((b) => b.data.startsWith("iq:") && b.data.endsWith(":skip")); let asOf: string | undefined; const parts = paragraphs(batch[qIndex].text).filter((p) => { const m = AS_OF.exec(p); if (m) asOf = m[1]; return !m; }); const footnotes = parts.filter((p) => p.startsWith("*")); const body = parts.filter((p) => !p.startsWith("*")); let title = body.pop() ?? ""; let anchors: string[] | undefined; if (ANCHORS.test(title) && body.length) { anchors = title.replace(/\.$/, "").split(/,\s*(?=\d+\s*—)/); title = body.pop() ?? ""; } const instruction = body .map((p) => (p.includes(DRINKS_START) ? p.slice(0, p.indexOf(DRINKS_START)).trim() : p)) .filter(Boolean); // Replies before the question in the same batch (e.g. «invalid answer» note) stay visible above it. const before = batch.slice(0, qIndex).map((r) => r.text.trim()).filter(Boolean); const input = options.length >= 5 && options.every((b) => /^\d{1,2}$/.test(b.label.trim())) ? "number" : options.length ? "choice" : skip ? "text" : // Numeric items carry no buttons: diary alcohol is counted in standard drinks, everything else in hours. /порци/i.test(title) ? "portions" : "hours"; return { kind: "survey", itemId, instruction: [...before, ...instruction], title, footnotes, options, input, anchors, asOf, skip, help, pause }; } export function classify(batch: Reply[]): Step | null { if (batch.length === 0) return null; const crisis = batch.find((r) => r.kind === "crisis"); if (crisis) return { kind: "crisis", text: crisis.text, buttons: batch.flatMap(flat) }; if (batch.some(isMenu)) { const notes = batch .filter((r) => !isMenu(r) || r.text.trim() !== MENU_PROMPT) .map((r) => r.text.trim()) .filter((t) => t && t !== MENU_PROMPT); return { kind: "idle", notes }; } const plan = batch.find((r) => r.kind === "safety_plan"); if (plan) { const [title, ...rest] = plan.text.split("\n").map((l) => l.trim()); const lead = batch.filter((r) => r !== plan).map((r) => r.text); return { kind: "plan", lead, title, lines: rest.filter(Boolean).map((l) => l.replace(/^[•\-–]\s*/, "")), buttons: batch.flatMap(flat), }; } // The question is the last reply with buttons; everything before it is context. const qIndex = batch.map((r) => r.buttons.length > 0).lastIndexOf(true); const texts = batch.map((r) => r.text); if (qIndex === -1) { // Without buttons the engine waits for free text only when it marks the reply as a question; anything else // («отметил приём») is a confirmation. if (!batch.some((r) => r.kind === "question")) return { kind: "ack", text: texts.filter(Boolean) }; const { lead, title } = titled(texts); return { kind: "text", lead, title }; } const options = flat(batch[qIndex]); const s = survey(batch, qIndex); if (s) return s; const resume = options.find((b) => b.data === "intake:resume" || b.data === "intake:start"); const helpOnly = options.length === 1 && options[0].data === "intake:help"; if ((resume && options.length === 1) || helpOnly) return { kind: "notice", text: texts.filter(Boolean), primary: resume, help: helpOnly ? options[0] : undefined }; const head = titled(texts.slice(0, qIndex + 1)); const tail = texts.slice(qIndex + 1); // rare: info after the question const lead = [...head.lead, ...tail]; if (options.some((b) => b.data === "consent:yes")) return { kind: "consent", lead, title: head.title, buttons: options }; if (options.every((b) => b.data.endsWith(":skip"))) return { kind: "text", lead, title: head.title, skip: options[0] }; if (options.length >= 5 && options.every((b) => /^\d{1,2}$/.test(b.label.trim()))) return { kind: "number", lead, title: head.title, options }; if (options.some((b) => /^med:(taken|skip|side_effects):/.test(b.data))) return { kind: "actions", lead, title: head.title, options }; const formId = /^scale:([^:]+):/.exec(options[0]?.data ?? "")?.[1]; return { kind: "choice", lead, title: head.title, options, progress: head.progress, formId }; } /** Blocks of an engine text: «• item» lines become list items; wrapped plain lines are joined into paragraphs. */ export function lines(text: string): { list: boolean; text: string }[] { const out: { list: boolean; text: string }[] = []; for (const raw of text.split("\n")) { const l = raw.trim(); if (!l) continue; if (/^[•\-–]\s+/.test(l)) out.push({ list: true, text: l.replace(/^[•\-–]\s+/, "") }); else if (out.length && !out[out.length - 1].list && !/[.!?:»)]$/.test(out[out.length - 1].text)) out[out.length - 1] = { list: false, text: `${out[out.length - 1].text} ${l}` }; else out.push({ list: false, text: l }); } return out; }