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>
This commit is contained in:
2026-10-04 15:14:09 +03:00
co-authored by Claude Opus 5.5
parent 6e7c89900b
commit 290b0d6151
36 changed files with 1900 additions and 691 deletions
+9 -2
View File
@@ -7,6 +7,7 @@ import type { DialogButton, Reply } from "../api/types";
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[] }
@@ -26,7 +27,7 @@ export interface Survey {
title: string; // the item text, verbatim
footnotes: string[]; // «*…» explanations printed under the item on the paper form
options: DialogButton[];
input: "choice" | "number" | "hours" | "text";
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;
@@ -107,7 +108,10 @@ function survey(batch: Reply[], qIndex: number): Survey | null {
? "choice"
: skip
? "text"
: "hours";
: // 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 };
}
@@ -142,6 +146,9 @@ export function classify(batch: Reply[]): Step | null {
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 };
}