Files
PTSRaiAgent/web/src/dialog/step.ts
T
Arxip222andClaude Opus 5.5 290b0d6151 feat(web): two-stage questionnaires, screening result and diary by result
- 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>
2026-10-04 15:14:09 +03:00

190 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}