feat(web): questionnaires, diary and medication module in the patient app

- Focus screen renders versioned questionnaire items (iq: buttons): section chip, position
  (adaptive forms without "of N"), recall date, pause / help-now actions, optional skip,
  sleep-hours input, 0-10 scale anchors, RUS-AUDIT item 2 standard-drink table.
- Paused / held-for-review questionnaires get their own screen.
- Today: intake and diary tasks, review notice, therapy-day card on days 1/7/14.
- Drug card /meds/:ref from data/meds/meds_reference.json (draft built from the Minzdrav
  guidelines: depression 2024, PTSD 2023) — expected effect, course, side effects, red flags;
  no doses shown. "Report a side effect" always available (med:side_effects:<ref>).
- API: /instruments/catalog, /meds/reference; today gains questionnaire state, reference id,
  indication and therapy day; demo persona seeds the dashboard dose history.
- INTAKE_DEMO=1 (demo clock only) opens the questionnaires on synthetic data without touching
  the clinical release gate in config/intake_runtime.yaml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 08:53:13 +03:00
co-authored by Claude Opus 5.5
parent 40c242bb1a
commit 464b474abd
22 changed files with 3393 additions and 58 deletions
+77 -3
View File
@@ -12,8 +12,27 @@ export type Step =
| { 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 }
| { kind: "text"; lead: string[]; title: string; skip?: 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" | "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;
@@ -24,6 +43,12 @@ export interface Progress {
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();
@@ -44,6 +69,48 @@ function titled(texts: string[]): { lead: string[]; title: string; progress?: Pr
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"
: "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;
@@ -79,6 +146,12 @@ export function classify(batch: Reply[]): Step | null {
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];
@@ -90,7 +163,8 @@ export function classify(batch: Reply[]): Step | null {
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 };
return { kind: "choice", lead, title: head.title, options, progress: head.progress };
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. */