- Sign-in: centred ЕМИАС card without the sidebar; anonymous entry asks «Депрессия / ПТСР».
- Stage 1 is the same for everyone and follows the fixed order of the MVP questionnaire:
PHQ-2 → trauma filter/TSQ → RUS-AUDIT-S → C-SSRS → context; stage 2 only where stage 1
is positive. Starts without a profile.
- After stage 1 the patient sees a preliminary result (screening, not a diagnosis), stored
with the record; it switches on the diary modules (depression / PTSD).
- Stage 2 diary: morning (sleep, sleep quality, alcohol) and evening (mood, medication,
3 PHQ-9 items in rotation, short PCL-5); C-SSRS since last contact only under risk;
flags on weekly means against the personal norm; scheduled full scales with change
(remission, response, worsening).
- Questionnaire log: GET /me/{token}/results and the «Мои результаты» screen.
- Questionnaires centred without the sidebar and without scale names; «Продолжить позже»
goes home; confirmations are toasts; free-text comments are optional.
- Demo policy config/questionnaire_demo.yaml (INTAKE_DEMO only, not a clinical approval).
ADR-012.
Refs #77
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
8.9 KiB
TypeScript
190 lines
8.9 KiB
TypeScript
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;
|
||
}
|