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:
+77
-3
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user