Compare commits

...
Author SHA1 Message Date
ElebobaandClaude Opus 5.5 251f946691 feat(backend): demo scheduler on the virtual clock — doses, missed, check-ins, scales, silence
CI / ruff · pytest (pull_request) Successful in 1m3s
- app/scheduler/demo.py: run_until(now) replays due and fresh events in time order: dose rows +
  med_reminder, missed after 2 h without an answer → monitoring, daily_checkin, scale_due by the
  config/modules.yaml schedules, daily monitoring pass (silence, missed doses) → reach_out/scale_due
- idempotent via scheduler_events (unique token+key); stale events are not sent; no contact without
  consent or during an open crisis
- config/scheduler.yaml: times and windows; runs while the questionnaire release is off
- POST /demo/clock/advance replays the scheduler up to the new time

Refs #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:25:44 +03:00
Eleboba 8c620419dd Merge pull request 'feat(backend): сервис мониторинга — снимок, правила, алерты, цвет, персистентность часов' (#58) from feat/38-monitoring into dev
CI / ruff · pytest (push) Successful in 1m6s
Reviewed-on: #58
2026-10-04 03:15:56 +03:00
ElebobaandClaude Opus 5.5 4c0836a1be feat(backend): monitoring service — snapshot, rules, alerts, status colour, clock persistence
CI / ruff · pytest (pull_request) Successful in 1m11s
- app/services/snapshot.py: PatientSnapshot from the clinical DB (field mapping, age, therapy day,
  recent C-SSRS risk, weapon access from dialog facts)
- app/services/monitoring.py: RuleMonitoring registered via register_monitoring; persists alert/crisis
  actions, crisis_acute audit, enhanced_monitoring flag, cached status_color, notifies the doctor (and
  the doctor on duty for crises) after commit; a running crisis is not restarted
- demo clock offset persisted in clock_state, restored on scheduler start
- tests: Andrey's 13 days through the service → G G G Y Y G G G G Y Y Y R

Refs #38

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 03:13:57 +03:00
Eleboba 20973bef17 Merge pull request 'fix(bot): пробелы движка — повтор C-SSRS, вступления шкал, данные в кнопках, тайм-аут шага' (#57) from fix/56-engine-gaps into dev
CI / ruff · pytest (push) Successful in 1m10s
2026-10-03 19:03:10 +03:00
ElebobaandClaude Opus 5.5 d06720fdab fix(bot): close dialog engine gaps (C-SSRS repeat, scale intros, button payloads, step timeout)
CI / ruff · pytest (pull_request) Successful in 1m19s
- No same-day C-SSRS repeat: a risk screen completed within cssrs_cooldown_hours is not queued again
  after PHQ-9 item 9 >= 1 (scale crisis path, rule schedule_scale, scale_due); crisis script and alerts stay.
- Scale intro registry: register_scale_intro(scale_id, text_or_fn); start_scale(intro=None) uses it.
  veteran_daily migrated from per-scale on_enter hooks.
- Prefix triggers "button:<prefix>:*" with trigger_payload(event); medication buttons carry the dose
  (med:taken:<dose_id>, ...), med_pending dropped; payload-less legacy buttons answer as stale.
- Abandoned-step timeout: settings.abandon_after_hours (default 6 h) returns abandonable states
  (daily.*, meds.*, scale.* not in onboarding) to idle before the next event; never in crisis or with intake on.

Refs #56

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 19:00:47 +03:00
Eleboba 2b7cbb8c2f Merge pull request 'feat(bot): ежедневный чек-ин ветерана, приём лекарств и реакции на правила' (#55) from feat/37-veteran-daily into dev
CI / ruff · pytest (push) Successful in 1m51s
2026-10-03 18:47:52 +03:00
ElebobaandClaude Opus 5.5 2972b330b0 test(bot): Andrey 13-day acceptance scenario and medication flow units
CI / ruff · pytest (pull_request) Successful in 1m3s
Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:45:53 +03:00
ElebobaandClaude Opus 5.5 2b13d54ed7 feat(bot): veteran daily check-in, medication reminders and rule reactions
Daily check-in (mood → dose → sleep/nightmares → alcohol → free text with
extract_checkin, buttons win over the LLM, progressive upsert of
daily_checkins), medication reminder flow (taken / skip with reason /
side effects → doses + daily_checkins), medication question menu via
med_qa, med_info rule reactions from the drug instruction excerpt,
«записаться раньше» request recorded for lane B, friendly intros for
scheduled scales. actions.py: additive med_info handler hook.

Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:45:53 +03:00
Eleboba 4c6e150162 Merge pull request 'feat(questionnaire): revised intake, diaries and follow-up monitoring' (#53) from feat/52-questionnaire into dev
CI / ruff · pytest (push) Successful in 59s
Reviewed-on: #53
2026-10-03 18:41:31 +03:00
Eleboba de5b1e05fb Merge pull request 'feat(bot): онбординг ветерана — скрининг PC-PTSD-5 → PCL-5, PHQ-9, AUDIT-C, вопрос об оружии' (#54) from feat/36-veteran-onboarding into dev
CI / ruff · pytest (push) Successful in 36s
2026-10-03 18:39:39 +03:00
ElebobaandClaude Opus 5.5 0a92266bab feat(bot): veteran onboarding — exposure, PC-PTSD-5, PCL-5, PHQ-9, AUDIT-C, weapon access
CI / ruff · pytest (pull_request) Successful in 32s
Start screening of the veteran module: peer-tone intro, trauma exposure gate for
PC-PTSD-5, PCL-5/PHQ-9/AUDIT-C via scale sessions (sex asked if unknown), weapon
access question stored as facts for lane B with a safe-storage block on risk,
plain-language result, modules enabled in profile, FAQ, resume after a crisis.

Refs #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:37:46 +03:00
Pavel Eliseev 0bd07045cd docs(clinical): attach visual PR implementation report
CI / ruff · pytest (pull_request) Successful in 56s
Refs #52
2026-10-03 18:33:57 +03:00
Pavel Eliseev c42ef621e5 feat(questionnaire): integrate revised intake and follow-up monitoring
CI / ruff · pytest (pull_request) Successful in 55s
Add gated TSQ, Russian C-SSRS Recent, durable responses and notifications, diaries, schedules and audited clinical review. Preserve manual assessment when the Russian follow-up source is unavailable.

Refs #52
2026-10-03 18:19:34 +03:00
Pavel Eliseev 8b61eecf1b docs(clinical): simplify translation review annotations
CI / ruff · pytest (pull_request) Successful in 30s
Refs #52
2026-10-03 17:41:01 +03:00
Pavel Eliseev fe5b6fe103 docs(clinical): review revised specification and backtranslate PCL-5
CI / ruff · pytest (pull_request) Successful in 38s
Refs #52
2026-10-03 17:38:54 +03:00
Pavel Eliseev 57b5ad1eb2 feat(core): add verified Russian C-SSRS Recent source content
CI / ruff · pytest (pull_request) Successful in 31s
2026-10-03 17:24:48 +03:00
Pavel Eliseev da089d67cb docs(clinical): normalize review table serialization
CI / ruff · pytest (pull_request) Successful in 30s
Refs #52
2026-10-03 16:16:01 +03:00
Pavel Eliseev 3cd7e2fd75 feat(core): add review-gated intake questionnaire rules
Correct RUS-AUDIT-S composition, preserve C-SSRS lifetime behavior, and prepare clinical review materials.

Refs #52
2026-10-03 16:13:36 +03:00
Eleboba f6e4828f01 Merge pull request 'feat(bot): Telegram-адаптер поверх движка диалога' (#51) from feat/11-telegram into dev
CI / ruff · pytest (push) Successful in 31s
2026-10-03 15:45:34 +03:00
Eleboba e884d896ff Merge pull request 'feat(bot): движок диалога — scrub → safety → FSM → мониторинг, согласие, возраст, кризис' (#50) from feat/10-dialog-engine into dev
CI / ruff · pytest (push) Successful in 26s
2026-10-03 15:44:11 +03:00
ElebobaandClaude Opus 5.5 fa40be3d94 feat(bot): dialog engine — scrub → safety → FSM → monitoring, consent, age gate, crisis
CI / ruff · pytest (pull_request) Successful in 25s
- Engine.handle registered via register_engine; lexicon-first safety (no LLM on ACUTE),
  fixed crisis script once per open crisis, POSSIBLE → C-SSRS screen
- FSM registry (@state/@on_enter/@trigger) with auto-loaded flows; core flow:
  new → consent → age_gate → module onboarding → idle, crisis, consent_revoked
- reusable scale session (buttons, extract_item + clarify, item 9 / C-SSRS buttons-only,
  crisis flags), execution of monitoring dialog actions, texts in config/texts/dialog.yaml

Refs #10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:42:36 +03:00
75 changed files with 10475 additions and 23 deletions
+98
View File
@@ -0,0 +1,98 @@
"""Audited questionnaire review for the synthetic prototype.
Like the existing API, doctor_id is supplied by the caller; production must put
these routes behind authenticated staff identity. No clinical data cross into identity.
"""
from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from app.core import clock
from app.db import repo
from app.db.audit import audit
from app.db.intake import QuestionnaireRecord
from app.db.session import get_clinical_session
from app.services import questionnaire as service
router = APIRouter(tags=["questionnaire-review"])
class ReviewIn(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
doctor_id: str = Field(min_length=1)
assessment_performed: Literal[True]
decision: Literal["resume", "keep_hold"]
reference: str = Field(min_length=1, max_length=200, pattern=r"^[A-Za-z0-9_./:#-]+$")
async def require_doctor(session, token, doctor_id):
profile = await repo.get_profile(session, token)
if profile is None or profile.doctor_id != doctor_id:
raise HTTPException(404, "patient not found")
@router.get("/patients/{token}/questionnaires")
async def history(token: str, doctor_id: str):
async with get_clinical_session() as session:
await require_doctor(session, token, doctor_id)
rows = await service.records(session, token)
await audit(session, actor=doctor_id, action="view_questionnaires", token=token, ts=clock.now())
return [
{
"id": r.id,
"kind": r.kind,
"status": r.status,
"version": r.version,
"policy_version": r.policy_version,
"started_at": r.started_at,
"completed_at": r.completed_at,
"answers": r.answers,
"details": r.details,
}
for r in rows
]
@router.post("/patients/{token}/questionnaires/{record_id}/review")
async def review(token: str, record_id: str, body: ReviewIn):
now = clock.now()
async with get_clinical_session() as session:
await service.lock_patient(session, token)
await require_doctor(session, token, body.doctor_id)
state = await repo.get_dialog_state(session, token)
row = await session.get(QuestionnaireRecord, record_id)
if row is None or row.token != token:
raise HTTPException(404, "record not found")
if not state or state.data.get("consent") is not True:
raise HTTPException(409, "consent required")
if row.status not in ("review_required", "interrupted"):
raise HTTPException(409, "record does not require review")
if body.decision == "resume" and (state.data.get("crisis") or {}).get("open"):
raise HTTPException(409, "close the acute episode after assessment before resuming routine work")
detail = {
"doctor_id": body.doctor_id,
"decision": body.decision,
"reference": body.reference,
"at": now.isoformat(),
}
row.details = {**row.details, "clinician_review": detail}
if body.decision == "resume":
row.status = "reviewed"
remaining = [
r
for r in await service.records(session, token)
if r.id != row.id and r.status in ("review_required", "interrupted")
]
if not remaining:
state.data = {**state.data, "intake_review_required": False, "questionnaire_record": None}
await audit(
session,
actor=body.doctor_id,
action="review_questionnaire",
token=token,
details={"record_id": row.id, "decision": body.decision, "reference": body.reference},
ts=now,
)
return {"id": row.id, "status": row.status, "review": detail}
+5 -1
View File
@@ -7,6 +7,7 @@ Lane B replaces bodies in #12 without changing paths or schemas (those are the f
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from app.api import fixtures from app.api import fixtures
from app.api.questionnaire import router as questionnaire_router
from app.api.schemas import ( from app.api.schemas import (
AckIn, AckIn,
AlertOut, AlertOut,
@@ -24,8 +25,10 @@ from app.api.schemas import (
from app.core import clock from app.core import clock
from app.dialog.contracts import get_engine from app.dialog.contracts import get_engine
from app.integrations.base import MemoryOutbox, integrations from app.integrations.base import MemoryOutbox, integrations
from app.scheduler import jobs as scheduler
router = APIRouter(prefix="/api/v1") router = APIRouter(prefix="/api/v1")
router.include_router(questionnaire_router)
# ------------------------------------------------------------------ doctor dashboard # ------------------------------------------------------------------ doctor dashboard
@@ -97,8 +100,9 @@ async def get_clock() -> ClockOut:
@router.post("/demo/clock/advance", response_model=ClockOut, tags=["demo"]) @router.post("/demo/clock/advance", response_model=ClockOut, tags=["demo"])
async def advance_clock(body: ClockAdvanceIn) -> ClockOut: async def advance_clock(body: ClockAdvanceIn) -> ClockOut:
"""Moves the virtual clock; #13 makes the scheduler replay all events up to the new time.""" """Moves the virtual clock and replays the scheduler up to the new time (#13)."""
clock.advance(days=body.days, hours=body.hours) clock.advance(days=body.days, hours=body.hours)
await scheduler.run_until_now()
return _clock_out() return _clock_out()
+59
View File
@@ -0,0 +1,59 @@
"""Russian Recent source transcription, separate from the legacy sequential scale.
This content does not authorize administering or continuing the questionnaire.
Use questionnaire.score_cssrs_recent for required/missing items and risk, and
evaluate_intake for the clinical policy gate before presenting another question.
Never render interviewer guidance as if it were another patient question.
"""
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings
ItemId = Literal["q1", "q2", "q3", "q4", "q5", "q6_lifetime", "q6_recent"]
class SourceModel(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class CssrsOption(SourceModel):
value: int = Field(strict=True, ge=0, le=1)
label: str
class CssrsItem(SourceModel):
id: ItemId
text: str = Field(min_length=1)
interviewer_guidance: str | None
recall_period: Literal["past_month", "lifetime", "past_3_months"]
yes_risk: Literal["low", "moderate", "high"]
class CssrsRussianForm(SourceModel):
id: Literal["cssrs_recent_ru"]
version: str = Field(min_length=1)
title: str
subtitle: Literal["Screen Version - Recent"]
period_label_original: str
initial_instruction: str
branch_instruction: str
options: tuple[CssrsOption, CssrsOption]
items: tuple[CssrsItem, ...]
@model_validator(mode="after")
def _structure(self) -> "CssrsRussianForm":
if tuple(item.id for item in self.items) != ("q1", "q2", "q3", "q4", "q5", "q6_lifetime", "q6_recent"):
raise ValueError("Recent form requires seven ordered answer fields")
if tuple(option.value for option in self.options) != (1, 0):
raise ValueError("source answer columns are Yes, No")
return self
def load_cssrs_russian(path: Path | None = None) -> CssrsRussianForm:
path = path or get_settings().config_dir / "instruments" / "cssrs_recent_ru.yaml"
return CssrsRussianForm.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
+141
View File
@@ -0,0 +1,141 @@
"""Question selection and exact versioned content, with no side effects."""
from dataclasses import dataclass
import yaml
from app.config import get_settings
from app.core.cssrs_content import load_cssrs_russian
from app.core.questionnaire import IntakePolicy, alcohol_expansion, score_alcohol, score_cssrs_recent
from app.core.scales import ScaleDef, load_scale
@dataclass(frozen=True)
class Question:
id: str
text: str
options: tuple[tuple[object, str], ...] = ()
period: str = "context"
numeric: bool = False
optional: bool = False
def content() -> dict:
return yaml.safe_load((get_settings().config_dir / "instruments/intake_forms.yaml").read_text())
def texts() -> dict:
return yaml.safe_load((get_settings().config_dir / "texts/intake.yaml").read_text())
def scale_question(scale_id: str, item_id: str) -> Question:
forms = content()["forms"]
scale = ScaleDef.model_validate(forms[scale_id]) if scale_id in forms else load_scale(scale_id)
item = next(i for i in scale.items if i.id == item_id)
instruction = scale.instruction or ""
# The beverage table is part of the approved questionnaire source.
if item_id == "rus_audit_2":
instruction += "\n" + texts()["standard_drinks"]
return Question(
item_id,
instruction + "\n\n" + item.text,
tuple((o.value, o.label) for o in item.options),
scale.recall_period or "",
)
def group_answers(answers: dict, prefix: str) -> dict[str, int]:
return {k: v for k, v in answers.items() if k.startswith(prefix + "_")}
def cssrs_answers(answers: dict) -> dict[str, int]:
return {k.removeprefix("cssrs_"): v for k, v in answers.items() if k.startswith("cssrs_")}
def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None:
c = score_cssrs_recent(cssrs_answers(answers))
if c.missing:
form = load_cssrs_russian()
item = next(i for i in form.items if i.id == c.missing[0])
period = texts()["periods"][item.recall_period]
# Guidance is interviewer-facing; retained in the source and record version.
return Question(
"cssrs_" + item.id,
period + "\n\n" + item.text,
tuple((o.value, o.label) for o in form.options),
item.recall_period,
)
for item in ("phq9_1", "phq9_2"):
if item not in answers:
return scale_question("phq9", item)
if "trauma" not in answers:
return Question(
"trauma", content()["trauma_introduction"] + "\n\n" + content()["trauma_filter"], ((1, "Да"), (0, "Нет"))
)
if answers["trauma"]:
for item in load_scale("tsq").item_ids:
if item not in answers:
return scale_question("tsq", item)
if "recent_trauma" not in answers:
return Question("recent_trauma", texts()["recent_trauma"], ((1, "Да"), (0, "Нет")))
for item in load_scale("rus_audit_s").item_ids:
if item not in answers:
return scale_question("rus_audit_s", item)
if sex is None and "sex" not in answers:
return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать")))
for key in ("tbi", "psychotropics"):
if key not in answers:
return Question(key, texts()[key], ((1, "Да"), (0, "Нет")))
if answers["phq9_1"] + answers["phq9_2"] >= policy.phq2_expand_at:
for item in load_scale("phq9").item_ids:
if item not in answers:
return scale_question("phq9", item)
if answers["trauma"] and sum(group_answers(answers, "tsq").values()) >= policy.tsq_expand_at:
for item in load_scale("pcl5").item_ids:
if item not in answers:
return scale_question("pcl5", item)
alcohol = group_answers(answers, "rus_audit")
selected_sex = sex or (answers.get("sex") if answers.get("sex") in ("m", "f") else None)
short = score_alcohol(
{k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids}, short=True, sex=selected_sex
)
if alcohol_expansion(short, policy):
for item in load_scale("rus_audit").item_ids:
if item == "rus_audit_2" and alcohol.get("rus_audit_1") == 0:
continue
if item not in answers:
return scale_question("rus_audit", item)
return None
def diary_questions(
modules: list[str], *, suicide_monitoring: bool, weekday: int, day_index: int | None = None
) -> tuple[Question, ...]:
config = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text())
selected = ["core"]
detailed = [name for name in ("veteran", "depression") if name in modules]
if len(detailed) == 2:
detailed = [detailed[(day_index if day_index is not None else weekday) % 2]]
if "veteran" in detailed:
selected.append("ptsd")
if "depression" in detailed:
selected.append("depression")
if suicide_monitoring or "depression" in modules:
selected.append("suicide")
questions = []
for section in selected:
for item in config[section]:
if item.get("weekly") and weekday != 0:
continue
questions.append(
Question(
item["id"],
item["text"],
tuple((o["value"], o["label"]) for o in item.get("options", [])),
"past_week" if item.get("weekly") else "past_day",
item.get("numeric", False),
item.get("optional", False),
)
)
questions.sort(key=lambda q: q.id != "death_thoughts")
return tuple(questions)
+153
View File
@@ -0,0 +1,153 @@
"""Versioned release gate and deterministic follow-up planning."""
from calendar import monthrange
from datetime import date, timedelta
from pathlib import Path
from typing import Literal
from zoneinfo import ZoneInfo
import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings
from app.core.questionnaire import Approval, load_policy
class RuntimePolicy(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
version: str
enabled: bool = False
approval: Approval | None = None
content_review: Approval | None = None
contact_review: Approval | None = None
timezone: str
daily_hour: int = Field(ge=0, le=23)
mood_baseline_days: int = Field(ge=2)
mood_bad_days: int = Field(ge=1)
mood_drop: int = Field(ge=1, le=10)
sleep_bad_days: int = Field(ge=1)
sleep_hours_below: float = Field(gt=0, le=24)
nightmares_at: int = Field(ge=0, le=4)
missed_medication_days: int = Field(ge=1)
high_risk_silence_days: int = Field(ge=1)
medication_days: tuple[int, ...]
medication_repeat_days: int = Field(ge=1)
route_intervals: dict[str, dict[str, int]]
alcohol_repeat_days: int = Field(ge=1)
phq9_retriage_delta: int = Field(ge=1)
pcl5_retriage_delta: int = Field(ge=1)
@model_validator(mode="after")
def valid(self):
ZoneInfo(self.timezone)
if set(self.route_intervals) != {"after_crisis", "specialist", "self_help"} or any(
set(row) != {"phq9", "pcl5"} for row in self.route_intervals.values()
):
raise ValueError("all route and scale intervals must be defined")
if not self.medication_days or tuple(sorted(set(self.medication_days))) != self.medication_days:
raise ValueError("medication days must be nonempty and strictly increasing")
if self.medication_days[0] < 1 or any(v < 1 for row in self.route_intervals.values() for v in row.values()):
raise ValueError("intervals must be positive")
if any(
a and a.policy_version != self.version for a in (self.approval, self.content_review, self.contact_review)
):
raise ValueError("review must match the runtime version")
return self
@property
def released(self) -> bool:
return self.enabled and all((self.approval, self.content_review, self.contact_review))
def load_runtime(path: Path | None = None) -> RuntimePolicy:
path = path or get_settings().config_dir / "intake_runtime.yaml"
return RuntimePolicy.model_validate(yaml.safe_load(path.read_text()))
def intake_enabled() -> bool:
return load_runtime().released and load_policy().approval is not None
def month_before(day: date, months: int = 1) -> date:
year, month = divmod(day.year * 12 + day.month - 1 - months, 12)
return day.replace(year=year, month=month + 1, day=min(day.day, monthrange(year, month + 1)[1]))
def due_scales(
*,
today: date,
enrolled: date,
last: dict[str, date],
route: Literal["specialist", "self_help"],
medication_changed: date | None = None,
after_crisis: date | None = None,
policy: RuntimePolicy | None = None,
) -> tuple[str, ...]:
"""Return each overdue form once. Therapy day zero is the recorded start/change date.
Due dates stay due until completion. Overlapping schedules choose the earlier
date; missed ticks do not create a burst of historical questionnaires.
"""
p = policy or load_runtime()
due = set()
for scale in ("phq9", "pcl5"):
completed = last.get(scale)
dates = [(completed or enrolled) + timedelta(days=p.route_intervals[route][scale])]
if after_crisis and after_crisis <= today:
dates.append(
max(completed or after_crisis, after_crisis) + timedelta(days=p.route_intervals["after_crisis"][scale])
)
if medication_changed and medication_changed <= today:
for offset in p.medication_days:
milestone = medication_changed + timedelta(days=offset)
if completed is None or completed < milestone:
dates.append(milestone)
break
else:
dates.append(
max(completed or medication_changed, medication_changed + timedelta(days=p.medication_days[-1]))
+ timedelta(days=p.medication_repeat_days)
)
if min(dates) <= today:
due.add(scale)
if (last.get("rus_audit_s") or enrolled) + timedelta(days=p.alcohol_repeat_days) <= today:
due.add("rus_audit_s")
return tuple(sorted(due))
def diary_flags(history: dict[date, dict], today: date, policy: RuntimePolicy | None = None) -> tuple[str, ...]:
"""Consecutive calendar dates; missing data never become zero or missed doses.
Baseline is the seven dates BEFORE the three-day deterioration window;
every baseline value is required. Nightmares OR short sleep must hold on
each of three nights. These candidate definitions require signed review.
"""
p = policy or load_runtime()
flags = []
current = history.get(today, {})
if current.get("death_thoughts") == 1:
flags.append("suicide_assessment_now")
recent = [history.get(today - timedelta(days=i), {}) for i in range(p.mood_bad_days)]
baseline = [
history.get(today - timedelta(days=i), {}).get("mood")
for i in range(p.mood_bad_days, p.mood_bad_days + p.mood_baseline_days)
]
if all(x is not None for x in baseline) and all(x.get("mood") is not None for x in recent):
mean = sum(baseline) / len(baseline)
if all(mean - x["mood"] >= p.mood_drop for x in recent):
flags.append("phq9_due_mood")
nights = [history.get(today - timedelta(days=i), {}) for i in range(p.sleep_bad_days)]
if all(
(r.get("nightmares") is not None and r["nightmares"] >= p.nightmares_at)
or (r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below)
for r in nights
):
flags.append("sleep_review")
doses = [history.get(today - timedelta(days=i), {}) for i in range(p.missed_medication_days)]
if all(r.get("medication") == "none" for r in doses):
flags.append("medication_contact")
if current.get("side_effects") == 1:
flags.append("side_effects_review")
if current.get("medication") == "partial":
flags.append("partial_medication_review")
return tuple(flags)
+337
View File
@@ -0,0 +1,337 @@
"""Versioned intake rules for clinical review (#52).
No patient wording or LLM calls. This module deliberately does not replace legacy
cssrs_screen (whose q6 only covers three months) or reinterpret historical data.
The dialog adapter must pass RAW answers, persist the policy version, and respect
the returned status. Pending clinical policy never authorizes routine continuation.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings
from app.core.scales import Sex, load_scale, score
Risk = Literal["none", "low", "moderate", "high"]
class Approval(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True)
reviewer: str = Field(min_length=1)
decision_reference: str = Field(min_length=1)
approved_at: datetime
policy_version: str = Field(min_length=1)
@model_validator(mode="after")
def _aware(self) -> Approval:
if self.approved_at.tzinfo is None:
raise ValueError("approval timestamp must include timezone")
return self
class IntakePolicy(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
version: str = Field(min_length=1)
approval: Approval | None = None
cssrs_positive_policy: Literal["pending", "stop_any", "graded"] = "pending"
rus_audit_s_expand_at: int | None = Field(default=None, ge=1, le=12, strict=True)
rus_audit_s_expand_from_zone: int | None = Field(default=None, ge=1, le=4, strict=True)
phq2_expand_at: int = Field(ge=1, le=6, strict=True)
tsq_expand_at: int = Field(ge=1, le=10, strict=True)
phq9_psychologist_at: int = Field(ge=0, le=27, strict=True)
phq9_specialist_at: int = Field(ge=0, le=27, strict=True)
phq9_priority_at: int = Field(ge=0, le=27, strict=True)
pcl5_specialist_at: int = Field(ge=0, le=80, strict=True)
pcl5_priority_at: int = Field(ge=0, le=80, strict=True)
pcl5_behavior_flag_at: int = Field(ge=0, le=4, strict=True)
@model_validator(mode="after")
def _approval_matches(self) -> IntakePolicy:
if self.approval and (
self.approval.policy_version != self.version
or self.cssrs_positive_policy == "pending"
or (self.rus_audit_s_expand_from_zone is None and self.rus_audit_s_expand_at is None)
):
raise ValueError("approval requires the matching version and resolved clinical decisions")
if self.rus_audit_s_expand_from_zone is not None and self.rus_audit_s_expand_at is not None:
raise ValueError("choose a total threshold OR a zone for alcohol expansion")
if not self.phq9_psychologist_at < self.phq9_specialist_at < self.phq9_priority_at:
raise ValueError("PHQ-9 thresholds must be ordered")
if self.pcl5_specialist_at >= self.pcl5_priority_at:
raise ValueError("PCL-5 thresholds must be ordered")
return self
def load_policy(path: Path | None = None) -> IntakePolicy:
path = path or get_settings().config_dir / "questionnaire.yaml"
return IntakePolicy.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
@dataclass(frozen=True)
class AlcoholResult:
scale: str
complete: bool
total: int | None
zone: int | None
missing: tuple[str, ...]
derived: tuple[str, ...] = ()
def score_alcohol(answers: dict[str, int], *, short: bool, sex: Sex | None) -> AlcoholResult:
"""RUS-AUDIT-S uses original items 3/9/10; unknown sex never borrows a cut-off."""
if sex not in (None, "m", "f"):
raise ValueError("sex must be m, f or None")
scale_id = "rus_audit_s" if short else "rus_audit"
# Validate BEFORE derivation: bools, floats and conflicting answers must not be hidden.
score(scale_id, answers, sex=sex)
values = dict(answers)
derived = []
if not short and values.get("rus_audit_1") == 0:
for item in ("rus_audit_2", "rus_audit_3"):
if values.get(item, 0) != 0:
raise ValueError("never drinks conflicts with positive consumption answers")
if item == "rus_audit_2" and item not in values:
values[item] = 0
derived.append(item)
result = score(scale_id, values, sex=sex)
missing = tuple(i for i in load_scale(scale_id).item_ids if i not in values)
zone = int(result.severity_code[-1]) if result.complete and sex is not None else None
return AlcoholResult(
scale_id, result.complete, result.total if result.complete else None, zone, missing, tuple(derived)
)
def extend_alcohol(short_answers: dict[str, int], additional: dict[str, int]) -> dict[str, int]:
"""Reuse original item IDs. Duplicate equal answers count once; conflicting ones fail."""
score("rus_audit_s", short_answers)
score("rus_audit", additional)
for key in short_answers.keys() & additional.keys():
if short_answers[key] != additional[key]:
raise ValueError(f"conflicting repeated answer: {key}")
return {**short_answers, **additional}
def alcohol_expansion(result: AlcoholResult, policy: IntakePolicy) -> bool | None:
"""None means unresolved, never a negative screen or permission to skip the full form."""
if result.scale != "rus_audit_s":
raise ValueError("expansion requires RUS-AUDIT-S")
if not policy.approval or not result.complete or result.zone is None:
return None
if policy.rus_audit_s_expand_at is not None:
return result.total >= policy.rus_audit_s_expand_at
assert policy.rus_audit_s_expand_from_zone is not None
return result.zone >= policy.rus_audit_s_expand_from_zone
_CSSRS_ITEMS = ("q1", "q2", "q3", "q4", "q5", "q6_lifetime", "q6_recent")
@dataclass(frozen=True)
class CssrsResult:
complete: bool
risk: Risk | None
risk_lower_bound: Risk
positive: bool
required: tuple[str, ...]
missing: tuple[str, ...]
conflicts: tuple[str, ...]
def score_cssrs_recent(answers: dict[str, int]) -> CssrsResult:
"""Recent screener structure; q1-5 month, q6 lifetime + conditional three-month follow-up.
These keys describe data, NOT an official Russian translation. Positive signals
survive missing/contradictory answers; only complete consistent data yield final risk.
"""
for key, value in answers.items():
if key not in _CSSRS_ITEMS or type(value) is not int or value not in (0, 1):
raise ValueError("C-SSRS requires known item IDs and integer 0/1 answers")
required = ["q1", "q2"]
if answers.get("q2") == 1:
required.extend(["q3", "q4", "q5"])
required.append("q6_lifetime")
if answers.get("q6_lifetime") == 1:
required.append("q6_recent")
conflicts = []
if answers.get("q2") == 0 and any(answers.get(k) == 1 for k in ("q3", "q4", "q5")):
conflicts.append("ideation_branch_conflict")
if answers.get("q6_lifetime") == 0 and answers.get("q6_recent") == 1:
conflicts.append("behavior_recency_conflict")
missing = tuple(k for k in required if k not in answers)
complete = not missing and not conflicts
lower: Risk = "none"
if any(answers.get(k) == 1 for k in ("q4", "q5", "q6_recent")):
lower = "high"
elif answers.get("q3") == 1 or answers.get("q6_lifetime") == 1:
lower = "moderate"
elif any(answers.get(k) == 1 for k in ("q1", "q2")):
lower = "low"
return CssrsResult(
complete, lower if complete else None, lower, 1 in answers.values(), tuple(required), missing, tuple(conflicts)
)
@dataclass(frozen=True)
class IntakeDecision:
policy_version: str
status: Literal["crisis", "clinical_review_required", "incomplete", "ready"]
primary_route: str | None
parallel_routes: tuple[str, ...]
flags: tuple[str, ...]
missing: tuple[str, ...]
def _additional_flags(pcl5, alcohol, sex, traumatic_brain_injury, psychotropics, policy):
"""Preserve independently observable flags even when the primary route is crisis.
Invalid unrelated data is marked for review rather than blocking a known crisis.
The routine branch still validates every answer strictly before routing.
"""
flags = []
try:
score("pcl5", pcl5)
if any(pcl5.get(k, 0) >= policy.pcl5_behavior_flag_at for k in ("pcl5_15", "pcl5_16")):
flags.append("pcl5_behavior")
except ValueError:
flags.append("invalid_pcl5_answers")
try:
full = score_alcohol(alcohol, short=False, sex=sex)
short_values = {k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids}
short = score_alcohol(short_values, short=True, sex=sex)
zone = full.zone if full.complete else short.zone
if zone is not None and zone >= 2:
flags.append("alcohol_zone_ii_or_higher")
if psychotropics and (any(v > 0 for v in alcohol.values())):
flags.append("alcohol_psychotropics")
if full.zone == 4:
flags.append("alcohol_zone_iv")
except ValueError:
flags.append("invalid_alcohol_answers")
if traumatic_brain_injury:
flags.append("traumatic_brain_injury")
return flags
def evaluate_intake(
*,
phq9: dict[str, int],
tsq: dict[str, int],
pcl5: dict[str, int],
alcohol: dict[str, int],
cssrs: dict[str, int],
sex: Sex | None,
trauma_exposure: bool | None,
help_now: bool = False,
traumatic_brain_injury: bool = False,
psychotropics: bool = False,
policy: IntakePolicy | None = None,
) -> IntakeDecision:
"""Two-stage candidate routing. No side effects, notifications, or claims of approval.
`alcohol` keeps full-scale IDs across both stages. Existing PHQ/PCL wording and
new C-SSRS content still need release review even with a signed routing policy.
"""
policy = policy or load_policy()
if type(help_now) is not bool or type(traumatic_brain_injury) is not bool or type(psychotropics) is not bool:
raise ValueError("context flags must be bool")
if trauma_exposure is not None and type(trauma_exposure) is not bool:
raise ValueError("trauma_exposure must be bool or None")
# Independent flags survive the higher-priority crisis route.
flags = _additional_flags(pcl5, alcohol, sex, traumatic_brain_injury, psychotropics, policy)
# A manual emergency request does not depend on the completeness of any scale.
if help_now:
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(["help_now", *flags]), ())
# Act on explicit safety answers BEFORE processing unrelated, possibly invalid scales.
phq_item9 = phq9.get("phq9_9")
item9_positive = type(phq_item9) is int and phq_item9 in (1, 2, 3)
try:
c = score_cssrs_recent(cssrs)
except ValueError:
known_high = any(type(cssrs.get(k)) is int and cssrs[k] == 1 for k in ("q4", "q5", "q6_recent"))
if not item9_positive and not known_high:
raise
flags.extend(["invalid_cssrs_answers", "phq9_item9_positive" if item9_positive else "cssrs_positive"])
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), ())
if item9_positive:
flags.append("phq9_item9_positive")
if c.complete and c.risk == "none":
flags.append("phq9_cssrs_discrepancy")
if c.positive:
flags.append("cssrs_positive")
flags.extend(c.conflicts)
if item9_positive or c.risk_lower_bound == "high":
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
if c.positive and policy.approval and policy.cssrs_positive_policy == "stop_any":
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
if c.positive and not policy.approval:
return IntakeDecision(policy.version, "clinical_review_required", None, (), tuple(flags), c.missing)
phq = score("phq9", phq9)
pc = score("tsq", tsq)
pcl = score("pcl5", pcl5)
full_alcohol = score_alcohol(alcohol, short=False, sex=sex)
short_values = {k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids}
short_alcohol = score_alcohol(short_values, short=True, sex=sex)
missing = list(c.missing)
phq2_items = ("phq9_1", "phq9_2")
missing.extend(k for k in phq2_items if k not in phq9)
phq2_complete = all(k in phq9 for k in phq2_items)
phq2_positive = phq2_complete and sum(phq9[k] for k in phq2_items) >= policy.phq2_expand_at
if phq2_positive:
missing.extend(k for k in load_scale("phq9").item_ids if k not in phq9)
if trauma_exposure is None:
missing.append("trauma_exposure")
if trauma_exposure is False and tsq:
flags.append("trauma_filter_conflict")
pc_positive = trauma_exposure is True and pc.complete and pc.total >= policy.tsq_expand_at
if trauma_exposure is True and not pc.complete:
missing.extend(k for k in load_scale("tsq").item_ids if k not in tsq)
if pc_positive and not pcl.complete:
missing.extend(k for k in load_scale("pcl5").item_ids if k not in pcl5)
# Partially administered optional scales also cannot be treated as completed negatives.
if len(phq9) > 2 and not phq.complete:
missing.extend(k for k in load_scale("phq9").item_ids if k not in phq9)
if pcl5 and not pcl.complete:
missing.extend(k for k in load_scale("pcl5").item_ids if k not in pcl5)
missing.extend(short_alcohol.missing)
if sex is None:
missing.append("alcohol_scoring_sex")
expand = alcohol_expansion(short_alcohol, policy)
if expand or (len(alcohol) > 3 and not full_alcohol.complete):
missing.extend(full_alcohol.missing)
if expand is None:
flags.append("alcohol_transition_unresolved")
if not policy.approval or c.conflicts or "trauma_filter_conflict" in flags:
return IntakeDecision(
policy.version, "clinical_review_required", None, (), tuple(flags), tuple(dict.fromkeys(missing))
)
if missing:
return IntakeDecision(policy.version, "incomplete", None, (), tuple(flags), tuple(dict.fromkeys(missing)))
# PHQ-2-negative does not invent a PHQ-9 total; it simply does not trigger expansion.
phq_total = phq.total if phq.complete else 0
pcl_total = pcl.total if pcl.complete else 0
if c.risk == "moderate":
primary = "urgent_psychiatrist"
elif phq_total >= policy.phq9_priority_at or pcl_total >= policy.pcl5_priority_at:
primary = "priority_psychiatrist"
elif phq_total >= policy.phq9_specialist_at or pcl_total >= policy.pcl5_specialist_at:
primary = "psychiatrist_or_psychotherapist"
elif phq_total >= policy.phq9_psychologist_at or pc_positive:
primary = "psychologist"
else:
primary = "self_help"
parallel = []
if full_alcohol.zone == 4:
parallel.append("narcologist")
if traumatic_brain_injury:
parallel.append("neurologist")
if c.risk == "low":
flags.append("cssrs_low_safety_plan")
return IntakeDecision(policy.version, "ready", primary, tuple(parallel), tuple(flags), ())
+2
View File
@@ -4,6 +4,8 @@ The only module (besides registration code) that touches both schemas — and on
""" """
from app.db import audit as _audit # noqa: F401 (registers audit_log in clinical metadata) from app.db import audit as _audit # noqa: F401 (registers audit_log in clinical metadata)
from app.db import intake as _intake # noqa: F401
from app.db import scheduler as _scheduler # noqa: F401
from app.db.clinical import ClinicalBase from app.db.clinical import ClinicalBase
from app.db.identity import IdentityBase from app.db.identity import IdentityBase
from app.db.session import clinical_engine, identity_engine from app.db.session import clinical_engine, identity_engine
+55
View File
@@ -0,0 +1,55 @@
"""Versioned questionnaire records, without modifying historical scale results."""
from datetime import date, datetime
from typing import Any
from sqlalchemy import Date, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.clinical import BigId, ClinicalBase
from app.db.types import JSONType, TZDateTime
class QuestionnaireRecord(ClinicalBase):
__tablename__ = "questionnaire_records"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
token: Mapped[str] = mapped_column(String(32), index=True)
kind: Mapped[str] = mapped_column(String(32))
version: Mapped[str] = mapped_column(String(64))
policy_version: Mapped[str] = mapped_column(String(64))
started_at: Mapped[datetime] = mapped_column(TZDateTime)
completed_at: Mapped[datetime | None] = mapped_column(TZDateTime)
day: Mapped[date] = mapped_column(Date)
status: Mapped[str] = mapped_column(String(32))
answers: Mapped[dict[str, Any]] = mapped_column(JSONType, default=dict)
# Per-field time, recall period, source and derived values; totals may be null.
details: Mapped[dict[str, Any]] = mapped_column(JSONType, default=dict)
class QuestionnaireNotice(ClinicalBase):
__tablename__ = "questionnaire_notices"
__table_args__ = (UniqueConstraint("token", "event_key"),)
id: Mapped[int] = mapped_column(BigId, primary_key=True, autoincrement=True)
token: Mapped[str] = mapped_column(String(32), index=True)
event_key: Mapped[str] = mapped_column(String(128))
alert_id: Mapped[int] = mapped_column(ForeignKey("alerts.id"))
created_at: Mapped[datetime] = mapped_column(TZDateTime)
attempted_at: Mapped[datetime | None] = mapped_column(TZDateTime)
accepted_at: Mapped[datetime | None] = mapped_column(TZDateTime)
attempts: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(String(32), default="pending")
error_code: Mapped[str | None] = mapped_column(String(80))
class QuestionnaireTask(ClinicalBase):
__tablename__ = "questionnaire_tasks"
__table_args__ = (UniqueConstraint("token", "task_key"),)
id: Mapped[int] = mapped_column(BigId, primary_key=True, autoincrement=True)
token: Mapped[str] = mapped_column(String(32), index=True)
task_key: Mapped[str] = mapped_column(String(128))
kind: Mapped[str] = mapped_column(String(32))
due_at: Mapped[datetime] = mapped_column(TZDateTime)
status: Mapped[str] = mapped_column(String(32), default="pending")
record_id: Mapped[str | None] = mapped_column(String(32))
delivery: Mapped[list] = mapped_column(JSONType, default=list)
sent_at: Mapped[datetime | None] = mapped_column(TZDateTime)
+24
View File
@@ -0,0 +1,24 @@
"""Delivery log of the demo scheduler (#13): one row per delivered event, the unique key makes runs idempotent.
Clinical DB, keyed by token only (guardrail #5). Created by `init_db` (`app.db.bootstrap`).
"""
from datetime import datetime
from sqlalchemy import String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.clinical import TOKEN_LEN, BigId, ClinicalBase
from app.db.types import TZDateTime
class SchedulerEvent(ClinicalBase):
__tablename__ = "scheduler_events"
__table_args__ = (UniqueConstraint("token", "key", name="uq_scheduler_events_token_key"),)
id: Mapped[int] = mapped_column(BigId, primary_key=True, autoincrement=True)
token: Mapped[str] = mapped_column(String(TOKEN_LEN))
key: Mapped[str] = mapped_column(String(96)) # e.g. "dose:3:2026-10-06T08:00", "checkin:2026-10-06"
kind: Mapped[str] = mapped_column(String(32)) # med_reminder | missed | daily_checkin | scale_due | evaluation
due_at: Mapped[datetime] = mapped_column(TZDateTime)
done_at: Mapped[datetime] = mapped_column(TZDateTime)
+85
View File
@@ -0,0 +1,85 @@
"""Execution of *dialog* actions returned by `MonitoringService.after_data_change()` (ADR-007).
The rules (lane B, `config/rules.yaml`) decide WHAT happens; this module only turns an already chosen action
into replies. `alert` / `enhanced_monitoring` are handled by lane B itself (persisted + doctor notified), so they
are ignored here. While a crisis is open only crisis-relevant actions (crisis, safety plan, risk screen) reach
the patient: no medication tips or visit offers in the middle of a crisis.
"""
from collections.abc import Awaitable, Callable, Iterable
from typing import Any
from app.core.rules import Action
from app.db.clinical import SafetyPlan
from app.dialog.contracts import Button, Reply
from app.dialog.crisis import crisis_open, open_crisis
from app.dialog.fsm import Ctx, Step
from app.dialog.scale_session import active_scale, add_pending
# med_info topic → phrase intent of `config/texts/phrases.yaml` (the LLM only rephrases the template).
_MED_INFO_INTENTS = {"delayed_effect": "quit_intent", "alcohol_interaction": "alcohol_info", "nausea": "nausea_info"}
_CRISIS_SAFE = {"crisis", "show_safety_plan", "schedule_scale"}
# Optional `med_info` answerer registered by a flow (`flows/meds.py`, #37): answers from the drug instruction
# (med_qa). Returning None falls back to the phrase template below.
MedInfoHandler = Callable[[Ctx, Action], Awaitable[Reply | None]]
_med_info_handler: MedInfoHandler | None = None
def register_med_info_handler(handler: MedInfoHandler | None) -> None:
global _med_info_handler
_med_info_handler = handler
def _plan_line(step: Any) -> str:
if isinstance(step, dict):
return ": ".join(str(v) for v in step.values() if v)
return str(step)
async def safety_plan_reply(ctx: Ctx, block: str | None = None) -> Reply:
"""The plan composed with the doctor (`safety_plans`), or the Stanley–Brown template from config."""
row = await ctx.session.get(SafetyPlan, ctx.token)
steps = [_plan_line(s) for s in row.steps] if row is not None and row.steps else ctx.texts.safety_plan.template
lines = [ctx.texts.safety_plan.title, *(f"{i}. {s}" for i, s in enumerate(steps, 1))]
if block == "means_restriction":
lines += ["", ctx.texts.safety_plan.means_restriction]
return Reply(text="\n".join(lines), kind="safety_plan")
async def execute(ctx: Ctx, actions: Iterable[Action]) -> Step:
step = Step()
shown_plan = False
for action in actions:
if crisis_open({**ctx.data, **step.data}) and action.type not in _CRISIS_SAFE:
continue
if action.type == "crisis":
if not crisis_open({**ctx.data, **step.data}):
step = step.then(open_crisis(ctx, categories=[], source=f"rule:{action.code}"))
elif action.type == "show_safety_plan" and not shown_plan:
shown_plan = True
step = step.then(Step(replies=[await safety_plan_reply(ctx, action.params.get("block"))]))
elif action.type == "schedule_scale" and action.params.get("scale"):
scale_id = str(action.params["scale"])
if active_scale(step.next_state or ctx.state) == scale_id:
continue # already being asked
data = {**ctx.data, **step.data}
pending = add_pending(data, scale_id, bool(action.params.get("scheduled", False)))
step = step.then(Step(data={"pending_scales": pending}))
elif action.type == "med_info":
reply = await _med_info_handler(ctx, action) if _med_info_handler is not None else None
if reply is None:
intent = _MED_INFO_INTENTS.get(str(action.params.get("topic")), "default")
reply = Reply(text=await ctx.gateway.phrase(intent, {}), kind="info")
step = step.then(Step(replies=[reply]))
elif action.type == "suggest_visit":
text = await ctx.gateway.phrase("book_visit_offer", {})
button = Button(label=ctx.texts.visit.button, data="menu:visit")
step = step.then(Step(replies=[Reply(text=text, buttons=[[button]], kind="info")]))
elif action.type == "reach_out":
topic = str(action.params.get("topic", "default"))
texts = ctx.texts.reach_out
text = {"silence": texts.silence, "missed_doses": texts.missed_doses}.get(topic, texts.default)
step = step.then(Step(replies=[Reply(text=text)]))
# alert / enhanced_monitoring: lane B persists and notifies; nothing to say to the patient.
return step
+49
View File
@@ -0,0 +1,49 @@
"""Crisis path of the dialog (guardrail #2): fixed texts only, never generated by the LLM.
Decisions:
* The crisis script (`app.core.safety.crisis_reply`, `config/texts/crisis.yaml`) is sent verbatim, once per
open crisis. "Open" = `data["crisis"]["open"]` — set when the crisis starts, cleared only by the doctor's
acknowledgement (`doctor_ack`), so a patient repeating the phrase does not get the same long script again:
they get a short fixed support message and emergency resources instead.
* Entering the crisis drops unfinished scale sessions (their answered items are already stored item by item):
nothing else is asked while the patient is in crisis, except the C-SSRS screen.
* Without valid consent (before consent, after refusal/revocation, minors) we still answer a crisis message —
protecting life is the one processing ground that does not need consent (152-FZ art. 10 p. 2.3) — but with
a variant WITHOUT "your doctor has been notified", because no doctor is notified, and nothing is stored.
"""
from typing import Any
from app.core.safety import CrisisResource, CrisisScript, crisis_reply
from app.dialog.contracts import Reply
from app.dialog.fsm import Ctx, Step
def crisis_open(data: dict[str, Any]) -> bool:
return bool((data.get("crisis") or {}).get("open"))
def _resource_lines(resources: list[CrisisResource]) -> list[str]:
return [f"• {r.name}: {r.phone}" + (f" ({r.note})" if r.note else "") for r in resources]
def open_crisis(ctx: Ctx, categories: list[str], source: str) -> Step:
"""Start the crisis (full script) or, if one is already open, send the short repeat text.
`source` is a code for the dashboard/audit trail: "safety:lexicon", "safety:llm", "scale:phq9", "rule:<id>".
"""
script = crisis_reply(categories)
if crisis_open(ctx.data):
text = "\n".join([ctx.texts.crisis.repeat, *_resource_lines(script.resources)])
return Step(replies=[Reply(text=text, kind="crisis")])
crisis = {"open": True, "opened_at": ctx.now.isoformat(), "source": source, "variant": script.variant}
return Step(
replies=[Reply(text=script.text, kind="crisis")],
next_state="crisis",
data={"crisis": crisis, "scales": None},
)
def no_consent_crisis_text(script: CrisisScript) -> str:
"""Crisis script without the "doctor notified" line — for people we may not (or did not) notify about."""
return "\n".join([script.body, "", *_resource_lines(script.resources), "", script.grounding])
+406
View File
@@ -0,0 +1,406 @@
"""Dialog engine (issue #10, ADR-004): `Engine.handle(token, event) -> list[Reply]`, channel-agnostic.
Pipeline for one incoming event (SPEC §3, guardrails #1, #2, #3):
0. abandoned step the FSM sits in a question step (check-in, scale) the patient left more than
`settings.abandon_after_hours` ago → back to idle first (stored answers stay; never in
crisis, never in an onboarding chain), then the event is handled as usual.
text event
1. scrub() raw text → ScrubbedText. Raw text is never stored, logged or sent anywhere.
2. consent gate no valid consent → no LLM, nothing stored; only the local crisis lexicon runs
(protecting life does not need consent) and the core flow shows consent/refusal.
3. safety.check() lexicon FIRST. Lexicon ACUTE → the LLM is not called at all. Otherwise the LLM risk
classifier is an auxiliary signal combined as max(lexicon, llm): it can only raise risk.
ACUTE → fixed crisis script verbatim (once per open crisis), state `crisis`, structured
`safety_events` fact for lane B, MonitoringService.after_data_change() (lane B alerts).
POSSIBLE → C-SSRS screen (scale session) and back to the previous state (or crisis if high).
4. FSM the current state's handler (`app/dialog/flows/*`, `app/dialog/scale_session.py`).
button / system / start events skip 1–3 (no free text) and go through the same FSM.
After the FSM step: if clinical data were written → commit → `after_data_change()` → the returned *dialog*
actions are executed (`app/dialog/actions.py`). The engine never creates alerts (ADR-007).
Then a queued scale starts if the FSM rests (idle; only the C-SSRS while in crisis).
Persist: inbound message (scrubbed) + outbound replies + `dialog_state` — only with active consent.
Logs carry only the token, states and event codes (guardrail #8).
"""
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any
from weakref import WeakValueDictionary
from sqlalchemy.exc import InterfaceError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import clock, safety
from app.core.intake_runtime import intake_enabled
from app.core.privacy import scrub
from app.core.safety import SafetyResult, crisis_reply
from app.core.types import RiskLevel, ScrubbedText
from app.db import repo
from app.db.session import get_clinical_session
from app.dialog import scale_session
from app.dialog.actions import execute
from app.dialog.contracts import Event, Reply, register_engine
from app.dialog.crisis import crisis_open, no_consent_crisis_text, open_crisis
from app.dialog.flows import load_flows
from app.dialog.flows.core import close_crisis, revoke
from app.dialog.fsm import (
NO_CONSENT_STATES,
RESTING_STATES,
Ctx,
Step,
apply_data,
enter_hook_for,
handler_for,
is_abandonable,
)
from app.dialog.texts import DialogTexts, load_dialog_texts
from app.llm.gateway import LLMGateway, get_gateway
from app.services import questionnaire as intake_service
from app.services.contracts import MonitoringService, get_monitoring
log = logging.getLogger(__name__)
# Enter hooks may chain (scale done → next onboarding state → next scale); a cycle must not hang a request.
MAX_ENTER_CHAIN = 8
_patient_locks = WeakValueDictionary()
# DB unreachable: answer safely instead of failing the channel (connectivity errors only — bugs must surface).
_DB_DOWN = (OSError, OperationalError, InterfaceError)
# dialog_state.data key: when the patient last acted in (or the FSM entered) the current step — abandon timeout.
STEP_AT = "step_at"
@dataclass
class _Run:
replies: list[Reply] = field(default_factory=list)
notify: bool = False
def _consented(ctx: Ctx) -> bool:
return ctx.data.get("consent") is True and ctx.state not in NO_CONSENT_STATES
class Engine:
"""Real `DialogEngine`. `gateway` / `monitoring` are injectable for tests; by default resolved per call so
a monitoring implementation registered later (lane B, #38) is picked up."""
def __init__(self, *, gateway: LLMGateway | None = None, monitoring: MonitoringService | None = None) -> None:
self._gateway = gateway
self._monitoring = monitoring
async def handle(self, token: str, event: Event) -> list[Reply]:
texts = load_dialog_texts()
try:
lock = _patient_locks.setdefault(token, asyncio.Lock())
async with lock:
async with get_clinical_session() as session:
await intake_service.lock_patient(session, token)
replies = await self._handle(token, event, session, texts)
await intake_service.dispatch_notices(token)
return replies
except _DB_DOWN as exc:
log.error("dialog db unavailable token=%s event=%s error=%s", token, event.type, type(exc).__name__)
return _degraded(event, texts)
# ------------------------------------------------------------------------------------------ pipeline
async def _handle(self, token: str, event: Event, session: AsyncSession, texts: DialogTexts) -> list[Reply]:
row = await repo.get_dialog_state(session, token)
ctx = Ctx(
token=token,
state=row.state if row is not None else "new",
data=dict(row.data or {}) if row is not None else {},
now=clock.now(),
session=session,
gateway=self._gateway or get_gateway(),
monitoring=self._monitoring or get_monitoring(),
texts=texts,
)
state_before = ctx.state
run = _Run()
if self._abandoned(ctx):
await self._apply(ctx, self._abandon(ctx), run)
await self._apply(ctx, await self._route(ctx, event), run)
if run.notify:
# Monitoring (lane B) reads the DB in its own session: commit our writes first.
await self._save_state(ctx)
await session.commit()
actions = await ctx.monitoring.after_data_change(token)
await self._apply(ctx, await execute(ctx, actions), run)
await self._apply(ctx, await self._start_pending(ctx), run)
if _consented(ctx):
for reply in run.replies:
# Outgoing texts may quote the safety plan (names of relatives) — scrub before storing too.
await repo.add_message(
session, token=token, direction="out", text=scrub(reply.text), ts=ctx.now, fsm_state=ctx.state
)
if event.type != "system" or ctx.state != state_before:
# System events the step ignores (reminders inside a scale) do not count as the patient being there.
ctx.data[STEP_AT] = ctx.now.isoformat()
await self._save_state(ctx)
task_id = event.params.get("questionnaire_task")
if event.type == "system" and task_id and run.replies:
from app.db.intake import QuestionnaireTask
task = await session.get(QuestionnaireTask, task_id)
if task and task.token == token:
task.delivery = [r.model_dump() for r in run.replies]
log.info(
"dialog token=%s event=%s code=%s state=%s->%s replies=%d",
token,
event.type,
_event_code(event),
state_before,
ctx.state,
len(run.replies),
)
return run.replies
def _abandoned(self, ctx: Ctx) -> bool:
"""The current question step was left unanswered longer than `abandon_after_hours` (app.core.clock)."""
if intake_enabled() or not _consented(ctx) or ctx.state in RESTING_STATES or crisis_open(ctx.data):
return False
hours = ctx.texts.settings.abandon_after_hours
last = ctx.data.get(STEP_AT)
if not hours or not last or ctx.now - datetime.fromisoformat(last) < timedelta(hours=hours):
return False
return is_abandonable(ctx)
def _abandon(self, ctx: Ctx) -> Step:
"""Drop the unfinished step's progress (answers already stored in item_responses / daily_checkins stay;
an unfinished scale gets no scale_results row) and rest in idle so queued scales may start."""
log.info("dialog step abandoned token=%s state=%s", ctx.token, ctx.state)
scale_id = scale_session.active_scale(ctx.state)
scales = {k: v for k, v in (ctx.data.get("scales") or {}).items() if k != scale_id}
return Step(next_state="idle", data={"checkin": None, "scales": scales or None})
async def _route(self, ctx: Ctx, event: Event) -> Step:
consented = _consented(ctx)
if event.type == "text":
# (1) scrub before anything else: from here on only ScrubbedText exists for this message.
ctx.text = scrub(event.text or "")
if consented and _is_revoke_command(event.text or "", ctx.texts):
return await revoke(ctx)
if not consented:
return await self._no_consent_text(ctx, event)
await self._store_in(ctx, ctx.text)
return await self._safety(ctx, event)
if event.type == "button":
if consented and event.button == "consent:revoke":
return await revoke(ctx)
if consented and event.button == "intake:help":
from app.db.intake import QuestionnaireRecord
from app.dialog.flows.intake import urgent
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
return await urgent(ctx, row)
if consented:
await self._store_in(ctx, scrub(f"[button] {event.button}")) # button data comes from the client
return await self._dispatch(ctx, event)
if event.type == "system":
if event.system == "doctor_ack":
return close_crisis(ctx)
if not consented:
return Step() # nothing is pushed to people without consent
if event.system == "scale_due" and event.params.get("scale"):
if intake_enabled() and ctx.data.get("adult") is True:
scale = str(event.params["scale"])
kind = "cssrs_since_contact" if scale in ("cssrs_screen", "cssrs") else scale
if kind in ("phq9", "pcl5", "rus_audit_s", "cssrs_since_contact"):
await intake_service.queue_task(
ctx.session, ctx.token, kind, f"external:{kind}:{ctx.now.date()}", ctx.now
)
return Step()
scheduled = bool(event.params.get("scheduled", True))
pending = scale_session.add_pending(ctx.data, str(event.params["scale"]), scheduled)
return Step(data={"pending_scales": pending}) # starts now if resting, else after the current step
return await self._dispatch(ctx, event)
return await self._dispatch(ctx, event) # start
async def _no_consent_text(self, ctx: Ctx, event: Event) -> Step:
assert ctx.text is not None
# Lexicon only: without consent the text must not reach the LLM (third-party processing).
result = safety.check(ctx.text)
if result.level == RiskLevel.ACUTE:
log.warning("safety acute without consent token=%s rules=%s", ctx.token, result.matched_rules)
return Step(replies=[Reply(text=no_consent_crisis_text(crisis_reply(result.categories)), kind="crisis")])
return await self._dispatch(ctx, event)
async def _safety(self, ctx: Ctx, event: Event) -> Step:
"""Guardrail #2: the safety decision is made before any LLM sees the message for anything else."""
assert ctx.text is not None
lexicon = safety.check(ctx.text)
if lexicon.level == RiskLevel.ACUTE:
result = lexicon # an acute message never reaches the LLM — not even the risk classifier
else:
result = safety.check(ctx.text, llm_signal=await ctx.gateway.risk_classify(ctx.text))
if result.level == RiskLevel.ACUTE:
handled = "crisis_repeat" if crisis_open(ctx.data) else "crisis_script"
log.warning("safety acute token=%s rules=%s handled=%s", ctx.token, result.matched_rules, handled)
if ctx.data.get("questionnaire_record") or (intake_enabled() and ctx.data.get("adult") is True):
from app.db.intake import QuestionnaireRecord
from app.dialog.flows.intake import urgent
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
step = await urgent(ctx, row, code="safety_acute", categories=result.categories)
else:
step = open_crisis(ctx, result.categories, source=f"safety:{result.source}")
step.data["safety_events"] = self._safety_events(ctx, result, handled)
step.notify_monitoring = True # lane B escalates to the doctor on call
return step
if result.level == RiskLevel.POSSIBLE and (
ctx.data.get("questionnaire_record") or (intake_enabled() and ctx.data.get("adult") is True)
):
from app.db.intake import QuestionnaireRecord
from app.dialog.flows.intake import manual_review
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
if row is None:
row = await intake_service.create_record(ctx, "cssrs_since_contact")
return await manual_review(ctx, row, "cssrs_since_contact_required")
if result.level == RiskLevel.POSSIBLE:
screen = ctx.texts.settings.risk_screen_scale
if self._can_screen(ctx, screen):
step = await scale_session.start_scale(
ctx, screen, return_state=ctx.state, scheduled=False, intro=ctx.texts.scale.risk_screen_intro
)
step.data["risk_screen_at"] = ctx.now.isoformat()
handled = "risk_screen"
else:
step = await self._dispatch(ctx, event)
handled = "dispatched"
log.info("safety possible token=%s rules=%s handled=%s", ctx.token, result.matched_rules, handled)
step.data["safety_events"] = self._safety_events(ctx, result, handled)
step.notify_monitoring = True
return step
return await self._dispatch(ctx, event)
def _can_screen(self, ctx: Ctx, screen: str) -> bool:
if crisis_open(ctx.data) or scale_session.active_scale(ctx.state) == screen:
return False
last = ctx.data.get("risk_screen_at")
if last is None:
return True
hours = (ctx.now - datetime.fromisoformat(last)).total_seconds() / 3600
return hours >= ctx.texts.settings.cssrs_cooldown_hours
def _safety_events(self, ctx: Ctx, result: SafetyResult, handled: str) -> list[dict[str, Any]]:
"""Structured safety fact for lane B (#38): no text, only codes. Newest last, capped."""
event = {
"ts": ctx.now.isoformat(),
"level": result.level.name.lower(),
"categories": result.categories,
"rules": result.matched_rules,
"source": result.source,
"handled": handled,
"state": ctx.state,
}
events = [*(ctx.data.get("safety_events") or []), event]
return events[-ctx.texts.settings.max_safety_events :]
async def _dispatch(self, ctx: Ctx, event: Event) -> Step:
handler = handler_for(ctx.state)
if handler is None:
# A state from a removed/renamed flow: recover instead of getting stuck.
log.error("dialog unknown state token=%s state=%s", ctx.token, ctx.state)
return Step(next_state="idle" if _consented(ctx) else "new")
return await handler(ctx, event)
async def _apply(self, ctx: Ctx, step: Step, run: _Run, depth: int = 0) -> None:
run.replies.extend(step.replies)
apply_data(ctx.data, step.data)
run.notify = run.notify or step.notify_monitoring
if not step.next_state or step.next_state == ctx.state:
return
ctx.state = step.next_state
hook = enter_hook_for(ctx.state)
if hook is None:
return
if depth >= MAX_ENTER_CHAIN:
log.error("dialog enter chain too long token=%s state=%s", ctx.token, ctx.state)
return
await self._apply(ctx, await hook(ctx), run, depth + 1)
async def _start_pending(self, ctx: Ctx) -> Step:
"""Start a queued scale when nothing else is being asked. In crisis only the risk screen may run."""
if intake_enabled():
return Step()
pending = list(ctx.data.get("pending_scales") or [])
if not pending or ctx.state not in RESTING_STATES:
return Step()
screen = ctx.texts.settings.risk_screen_scale
dropped: dict[str, Any] = {}
if any(p["scale"] == screen for p in pending) and await scale_session.recent_risk_screen(ctx, screen):
# Item 9 / rule phq9_item9 / scale_due asked for a C-SSRS the patient completed within the cooldown:
# do not repeat the same suicide screen (#56). Crisis script and alerts are unaffected.
log.info("dialog risk screen skipped (cooldown) token=%s", ctx.token)
pending = [p for p in pending if p["scale"] != screen]
dropped = {"pending_scales": pending or None}
if not pending:
return Step(data=dropped)
if ctx.state == "crisis":
pending_screen = [p for p in pending if p["scale"] == screen]
if not pending_screen:
return Step(data=dropped)
item = pending_screen[0]
else:
item = pending[0]
rest = [p for p in pending if p is not item]
step = await scale_session.start_scale(
ctx,
item["scale"],
return_state=ctx.state,
scheduled=bool(item.get("scheduled", True)),
intro=ctx.texts.scale.risk_screen_intro if item["scale"] == screen else None,
)
step.data["pending_scales"] = rest or None
return step
async def _store_in(self, ctx: Ctx, text: ScrubbedText) -> None:
await repo.add_message(ctx.session, token=ctx.token, direction="in", text=text, ts=ctx.now, fsm_state=ctx.state)
async def _save_state(self, ctx: Ctx) -> None:
await repo.set_dialog_state(ctx.session, token=ctx.token, state=ctx.state, data=ctx.data, now=ctx.now)
def _is_revoke_command(raw: str, texts: DialogTexts) -> bool:
return raw.strip().lower().rstrip(".!") in texts.commands.revoke
def _event_code(event: Event) -> str:
if event.type == "button":
return event.button or ""
if event.type == "system":
return event.system or ""
return "-"
def _degraded(event: Event, texts: DialogTexts) -> list[Reply]:
"""DB down: still answer a crisis message (lexicon only, no "doctor notified" — nobody could be notified)."""
if event.type == "text" and event.text:
result = safety.check(scrub(event.text))
if result.level == RiskLevel.ACUTE:
return [Reply(text=no_consent_crisis_text(crisis_reply(result.categories)), kind="crisis")]
if event.type == "system":
return []
return [Reply(text=texts.unavailable, kind="info")]
load_flows()
register_engine(Engine)
+16
View File
@@ -0,0 +1,16 @@
"""Dialog flows. Each module here registers its FSM states at import time (`@state`, `@on_enter`, `@trigger`).
A new direction (veteran #36/#37, family, depression) is a new file `app/dialog/flows/<module>.py`; it is picked
up automatically by `load_flows()` — the engine is not edited.
"""
import pkgutil
from importlib import import_module
def load_flows() -> None:
"""Import every flow module of this package (core first, so flows may override core triggers)."""
import_module(f"{__name__}.core")
for info in sorted(pkgutil.iter_modules(__path__), key=lambda m: m.name):
if info.name != "core" and not info.name.startswith("_"):
import_module(f"{__name__}.{info.name}")
+352
View File
@@ -0,0 +1,352 @@
"""Core flow shared by all modules: connect → consent → age gate → module onboarding → idle; crisis; revocation.
new ──start(code)──▶ consent ──«Согласен»──▶ age_gate ──adult──▶ <module>.onboarding.start … ──▶ idle
│ └──< 18──▶ minor (stop, child helpline)
└──«Не согласен»──▶ refused (stop, /start to reconsider)
any consented state ──«Отозвать согласие»──▶ consent_revoked (silent; /start → consent again)
any ──ACUTE / item 9 / C-SSRS high──▶ crisis ──doctor_ack──▶ idle
Consent and age are the registration step, so this module (and only it) touches the identity DB, through
`app.db.identity_repo`, and only for consent / revocation / birth year (guardrail #5).
"""
import re
from app.db import identity_repo, repo
from app.db.session import get_identity_session
from app.dialog.actions import safety_plan_reply
from app.dialog.contracts import Button, Event, Reply
from app.dialog.fsm import Ctx, Step, get_trigger, is_registered, on_enter, state, trigger
from app.integrations.base import integrations
from app.services.contracts import NoopMonitoring
ADULT_AGE = 18
_YEAR_RE = re.compile(r"\b(19\d{2}|20\d{2})\b")
_AGE_RE = re.compile(r"^\s*(\d{1,3})\s*(лет|год|года)?\s*$")
# ------------------------------------------------------------------------------------------- replies
def consent_reply(ctx: Ctx) -> Reply:
t = ctx.texts.consent
buttons = [[Button(label=t.accept, data="consent:yes")], [Button(label=t.decline, data="consent:no")]]
return Reply(text=t.text, buttons=buttons, kind="question")
def menu_reply(ctx: Ctx, text: str | None = None) -> Reply:
b = ctx.texts.idle.buttons
buttons = [
[Button(label=b.checkin, data="menu:checkin")],
[Button(label=b.med_question, data="menu:med_question")],
[Button(label=b.contact_doctor, data="menu:contact_doctor")],
[Button(label=b.revoke, data="consent:revoke")],
]
from app.core.intake_questions import texts
from app.core.intake_runtime import intake_enabled
if intake_enabled():
buttons.insert(0, [Button(label=texts()["help"], data="intake:help")])
buttons.insert(
0,
[
Button(
label=texts()["resume"] if ctx.data.get("questionnaire_record") else texts()["start"],
data="intake:resume" if ctx.data.get("questionnaire_record") else "intake:start",
)
],
)
return Reply(text=text or ctx.texts.idle.menu_prompt, buttons=buttons)
# ------------------------------------------------------------------------------------------- onboarding API
async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str:
"""Entry state of the next module onboarding: `"{module}.onboarding.start"` for the first module of the
profile (after `after`, if given) that has such a state registered; `"idle"` when none is left."""
profile = await ctx.profile()
modules = list(profile.modules) if profile is not None and profile.modules else []
from app.core.intake_runtime import intake_enabled
if after is None and intake_enabled() and any(m in modules for m in ("veteran", "depression")):
return "intake.start"
if after in modules:
modules = modules[modules.index(after) + 1 :]
for module in modules:
if is_registered(f"{module}.onboarding.start"):
return f"{module}.onboarding.start"
return "idle"
async def finish_onboarding(ctx: Ctx, module: str | None = None) -> Step:
"""A module flow calls this when its onboarding is done: next module's onboarding, or idle + welcome."""
nxt = await onboarding_entry(ctx, after=module)
if nxt == "idle":
return Step(replies=[menu_reply(ctx, ctx.texts.idle.welcome)], next_state="idle")
return Step(next_state=nxt)
# ------------------------------------------------------------------------------------------- new / consent
@state("new")
async def _new(ctx: Ctx, event: Event) -> Step:
code = event.params.get("code") if event.type == "start" else None
if code:
# Telegram deep link carries the doctor's one-time connect code; the web console passes the token
# directly (no code) — then the token is already known to the system.
person = await integrations.identity.verify(str(code))
if person is None or person.token != ctx.token:
return Step(replies=[Reply(text=ctx.texts.consent.code_invalid)])
if await ctx.profile() is None:
await repo.upsert_profile(ctx.session, token=ctx.token, doctor_id=person.doctor_id)
return Step(replies=[consent_reply(ctx)], next_state="consent")
@state("consent")
async def _consent(ctx: Ctx, event: Event) -> Step:
if event.type == "button" and event.button == "consent:yes":
version = ctx.texts.consent.version
async with get_identity_session() as s:
# None when the token has no identity record (web demo console) — the dialog state keeps the fact.
await identity_repo.record_consent(s, token=ctx.token, version=version, now=ctx.now)
data = {"consent": True, "consent_ver": version, "consent_at": ctx.now.isoformat(), "revoked_at": None}
return Step(next_state="age_gate", data=data)
if event.type == "button" and event.button == "consent:no":
return _refused_step(ctx)
# Anything else (incl. free text) is not processed before consent: show the consent text again.
return Step(replies=[consent_reply(ctx)])
def _refused_step(ctx: Ctx) -> Step:
button = Button(label=ctx.texts.consent.reconsider, data="consent:restart")
return Step(replies=[Reply(text=ctx.texts.consent.refused, buttons=[[button]])], next_state="refused")
@state("refused")
async def _refused(ctx: Ctx, event: Event) -> Step:
if event.type == "start" or (event.type == "button" and event.button == "consent:restart"):
return Step(replies=[consent_reply(ctx)], next_state="consent")
if event.type == "system":
return Step()
return _refused_step(ctx)
# ------------------------------------------------------------------------------------------- age gate
def _age_verdict(birth_year: int, current_year: int) -> str:
"""Without the birthday the age is `current - birth_year` or one less; ask when the two disagree on 18."""
oldest, youngest = current_year - birth_year, current_year - birth_year - 1
if youngest >= ADULT_AGE:
return "adult"
if oldest < ADULT_AGE:
return "minor"
return "unsure"
def _age_question(ctx: Ctx, note: str = "") -> Reply:
t = ctx.texts.age_gate
buttons = [[Button(label=t.adult, data="age:adult")], [Button(label=t.minor, data="age:minor")]]
return Reply(text="\n\n".join(p for p in (note, t.question) if p), buttons=buttons, kind="question")
@on_enter("age_gate")
async def _age_enter(ctx: Ctx) -> Step:
profile = await ctx.profile()
birth_year = profile.birth_year if profile is not None else None
if birth_year is None:
async with get_identity_session() as s:
patient = await identity_repo.get_patient(s, ctx.token)
birth_year = patient.birth_year if patient is not None else None
if birth_year is not None:
verdict = _age_verdict(birth_year, ctx.now.year)
if verdict == "adult":
return await _adult(ctx)
if verdict == "minor":
return await _minor(ctx)
return Step(replies=[_age_question(ctx)])
@state("age_gate")
async def _age_gate(ctx: Ctx, event: Event) -> Step:
if event.type == "button" and event.button == "age:adult":
return await _adult(ctx)
if event.type == "button" and event.button == "age:minor":
return await _minor(ctx)
if event.type == "text" and ctx.text:
year = _YEAR_RE.search(ctx.text)
age_match = _AGE_RE.match(ctx.text)
if year:
birth_year = int(year.group(1))
verdict = _age_verdict(birth_year, ctx.now.year)
if verdict == "adult":
return await _adult(ctx, birth_year)
if verdict == "minor":
return await _minor(ctx)
elif age_match:
age = int(age_match.group(1))
return await (_adult(ctx) if age >= ADULT_AGE else _minor(ctx))
return Step(replies=[_age_question(ctx, ctx.texts.age_gate.unclear)])
return Step(replies=[_age_question(ctx)])
async def _adult(ctx: Ctx, birth_year: int | None = None) -> Step:
if birth_year is not None and await ctx.profile() is not None:
await repo.upsert_profile(ctx.session, token=ctx.token, birth_year=birth_year)
return Step(data={"adult": True}).then(await finish_onboarding(ctx))
async def _minor(ctx: Ctx) -> Step:
# A minor cannot give this consent on their own: withdraw it and stop processing.
async with get_identity_session() as s:
await identity_repo.revoke_consent(s, token=ctx.token, now=ctx.now)
return Step(
replies=[Reply(text=ctx.texts.age_gate.minor_stop, kind="info")],
next_state="minor",
data={"consent": False, "minor": True},
)
@state("minor")
async def _minor_state(ctx: Ctx, event: Event) -> Step:
if event.type == "system":
return Step()
return Step(replies=[Reply(text=ctx.texts.age_gate.minor_stop, kind="info")])
# ------------------------------------------------------------------------------------------- idle
@state("idle")
async def _idle(ctx: Ctx, event: Event) -> Step:
if event.type == "start":
return Step(replies=[menu_reply(ctx)])
if event.type == "button" and event.button:
handler = get_trigger(f"button:{event.button}")
return await handler(ctx, event) if handler else Step(replies=[menu_reply(ctx)])
if event.type == "system" and event.system:
handler = get_trigger(f"system:{event.system}")
return await handler(ctx, event) if handler else Step()
if event.type == "text":
handler = get_trigger("text:idle")
if handler:
return await handler(ctx, event)
# Neutral empathic reply; the LLM only rephrases the approved template (and is filtered).
return Step(replies=[menu_reply(ctx, await ctx.gateway.phrase("support"))])
return Step()
# Default idle triggers — flows (#15, #36, #37) override them with `@trigger(..., replace=True)`.
@trigger("button:menu:checkin")
async def _menu_checkin(ctx: Ctx, event: Event) -> Step:
return Step(replies=[Reply(text=ctx.texts.idle.stub_checkin)])
@trigger("button:menu:med_question")
async def _menu_med_question(ctx: Ctx, event: Event) -> Step:
return Step(replies=[Reply(text=ctx.texts.idle.stub_med_question)])
@trigger("button:menu:contact_doctor")
async def _menu_contact_doctor(ctx: Ctx, event: Event) -> Step:
# A fact for lane B (#38): the patient asked the doctor to get in touch. No alert is created here (ADR-007).
requests = [*(ctx.data.get("contact_requests") or []), {"ts": ctx.now.isoformat()}][-20:]
return Step(
replies=[Reply(text=ctx.texts.idle.contact_doctor_ack)],
data={"contact_requests": requests},
notify_monitoring=True,
)
@trigger("button:menu:visit")
async def _menu_visit(ctx: Ctx, event: Event) -> Step:
return Step(replies=[Reply(text=ctx.texts.idle.visit_stub)])
@trigger("button:safety_plan")
async def _show_plan(ctx: Ctx, event: Event) -> Step:
return Step(replies=[await safety_plan_reply(ctx)])
@trigger("system:med_reminder")
async def _med_reminder(ctx: Ctx, event: Event) -> Step:
drug = str(event.params.get("drug", ""))
return Step(replies=[Reply(text=await ctx.gateway.phrase("med_reminder", {"drug": drug}))])
@trigger("system:daily_checkin")
async def _daily_checkin(ctx: Ctx, event: Event) -> Step:
return Step(replies=[menu_reply(ctx, ctx.texts.idle.stub_checkin)])
@trigger("system:reach_out")
async def _reach_out(ctx: Ctx, event: Event) -> Step:
return Step(replies=[Reply(text=ctx.texts.reach_out.default)])
# ------------------------------------------------------------------------------------------- crisis
def close_crisis(ctx: Ctx) -> Step:
"""Doctor acknowledged the crisis: clear the flag; a C-SSRS still running returns to idle afterwards."""
if not (ctx.data.get("crisis") or {}).get("open") and ctx.state != "crisis":
return Step()
crisis = {**(ctx.data.get("crisis") or {}), "open": False, "closed_at": ctx.now.isoformat()}
scales = {
sid: {**s, "return_state": "idle"} if s.get("return_state") == "crisis" else s
for sid, s in (ctx.data.get("scales") or {}).items()
}
step = Step(data={"crisis": crisis, "scales": scales})
if ctx.state == "crisis":
step.next_state = "idle"
step.replies.append(Reply(text=ctx.texts.crisis.closed))
return step
@state("crisis")
async def _crisis(ctx: Ctx, event: Event) -> Step:
# The NoopMonitoring stub knows nothing about crises and always says "not in crisis"; trusting it would
# end every crisis on the next message. Until lane B (#38) lands only `doctor_ack` ends a crisis.
if not isinstance(ctx.monitoring, NoopMonitoring) and not await ctx.monitoring.is_in_crisis(ctx.token):
return close_crisis(ctx)
if event.type == "system":
return Step() # reminders and check-ins wait until the crisis is over
if event.type == "button" and event.button == "safety_plan":
return Step(replies=[await safety_plan_reply(ctx)])
plan = Button(label=ctx.texts.safety_plan.title.rstrip(":"), data="safety_plan")
return Step(replies=[Reply(text=ctx.texts.crisis.hold, buttons=[[plan]], kind="crisis")])
# ------------------------------------------------------------------------------------------- revocation
async def revoke(ctx: Ctx) -> Step:
"""Withdraw consent in one step (152-FZ art. 9 p. 2): identity DB + dialog state; the bot goes silent.
Clinical data are marked for deletion (`data["delete_requested"]`) — erasure itself is lane B's job.
"""
async with get_identity_session() as s:
await identity_repo.revoke_consent(s, token=ctx.token, now=ctx.now)
from app.services.questionnaire import cancel_pending
await cancel_pending(ctx.session, ctx.token)
data = {
"questionnaire_record": None,
"consent": False,
"revoked_at": ctx.now.isoformat(),
"delete_requested": True,
"scales": None,
"pending_scales": None,
}
return Step(replies=[Reply(text=ctx.texts.revoked.confirm)], next_state="consent_revoked", data=data)
@state("consent_revoked")
async def _revoked(ctx: Ctx, event: Event) -> Step:
if event.type == "start":
return Step(replies=[consent_reply(ctx)], next_state="consent")
return Step() # silent: nothing is processed after revocation
+376
View File
@@ -0,0 +1,376 @@
"""Versioned, resumable questionnaires. Disabled until the clinical release gate opens."""
import math
from dataclasses import asdict, replace
from zoneinfo import ZoneInfo
from app.core.intake_questions import (
content,
cssrs_answers,
diary_questions,
group_answers,
next_intake,
scale_question,
texts,
)
from app.core.intake_runtime import diary_flags, intake_enabled, load_runtime
from app.core.questionnaire import alcohol_expansion, load_policy, score_alcohol, score_cssrs_recent
from app.core.scales import load_scale
from app.core.types import AlertLevel
from app.db.intake import QuestionnaireRecord, QuestionnaireTask
from app.dialog.contracts import Button, Event, Reply
from app.dialog.crisis import crisis_open, open_crisis
from app.dialog.fsm import Step, on_enter, state, trigger
from app.services import questionnaire as service
def allowed(ctx):
return ctx.data.get("consent") is True and ctx.data.get("adult") is True and intake_enabled()
def next_question(row):
a = row.answers
if row.kind == "intake":
return next_intake(a, load_policy(), row.details.get("sex"))
if row.kind == "diary":
for q in diary_questions(
row.details["modules"], suicide_monitoring=row.details["suicide_monitoring"], weekday=row.day.weekday()
):
if q.id in a:
continue
if q.id == "medication_reason" and a.get("medication") not in ("partial", "none"):
continue
if q.id == "side_effects" and a.get("medication") == "not_prescribed":
continue
if q.id == "thought_intensity" and a.get("death_thoughts") != 1:
continue
return q
return None
if row.kind in ("phq9", "pcl5", "rus_audit_s"):
sid = row.kind
if sid == "rus_audit_s":
short = score_alcohol(
{k: a[k] for k in load_scale(sid).item_ids if k in a}, short=True, sex=row.details.get("sex")
)
if alcohol_expansion(short, load_policy()):
sid = "rus_audit"
for item in load_scale(sid).item_ids:
if item == "rus_audit_2" and a.get("rus_audit_1") == 0:
continue
if item not in a:
q = scale_question(sid, item)
if sid == "pcl5" and row.details["weekly_pcl"]:
q = replace(q, text=q.text.replace("последний месяц", "последнюю неделю"), period="past_week")
return q
if row.details.get("medication_active") and "side_effects" not in a:
return next(
q
for q in diary_questions([], suicide_monitoring=False, weekday=row.day.weekday())
if q.id == "side_effects"
)
return None
def question_reply(row, q):
t = texts()
# Nonce + item + option index rejects old/replayed callbacks without exposing raw answers.
prefix = f"iq:{row.id[:12]}:{q.id}:"
buttons = [[Button(label=label, data=prefix + str(i))] for i, (_, label) in enumerate(q.options)]
if q.optional:
buttons.append([Button(label=t["comment_skip"], data=prefix + "skip")])
buttons.extend([[Button(label=t["help"], data="intake:help")], [Button(label=t["pause"], data="intake:pause")]])
return Reply(text=q.text + "\n\n" + t["as_of"].format(day=row.day.isoformat()), buttons=buttons, kind="question")
async def urgent(ctx, row=None, *, code="help_now", categories=None):
if crisis_open(ctx.data):
return open_crisis(ctx, categories or [], source="questionnaire:" + code)
profile = await ctx.profile()
if profile:
profile.enhanced_monitoring = True
flags = list(service.decision(row).flags) if row else []
if row and (row.answers.get("death_thoughts") == 1 or row.answers.get("phq9_9", 0) > 0):
flags.append("cssrs_since_contact_required")
if row:
await service.finish(ctx, row, "interrupted", extra={"stop_reason": code, "flags": flags})
await service.alert(
ctx.session,
ctx.token,
f"{row.id if row else ctx.now.isoformat()}:{code}",
code,
ctx.now,
level=AlertLevel.ACUTE,
params={"record_id": row.id if row else None, "flags": flags},
)
step = open_crisis(ctx, categories or [], source="questionnaire:" + code)
step.data["questionnaire_record"] = None
step.data["intake_review_required"] = True
for reply in step.replies:
reply.buttons.append([Button(label=ctx.texts.safety_plan.title.rstrip(":"), data="safety_plan")])
return step
async def manual_review(ctx, row, code):
await service.finish(ctx, row, "review_required", extra={"stop_reason": code})
await service.alert(
ctx.session, ctx.token, f"{row.id}:{code}", code, ctx.now, level=AlertLevel.HIGH, params={"record_id": row.id}
)
return Step(
replies=[
Reply(
text=texts()["missing_followup"] if code == "cssrs_since_contact_required" else texts()["review"],
buttons=[[Button(label=texts()["help"], data="intake:help")]],
kind="info",
)
],
next_state="idle",
data={"questionnaire_record": None, "intake_review_required": True},
)
async def advance(ctx, row):
a = row.answers
c = score_cssrs_recent(cssrs_answers(a))
policy = load_policy()
if a.get("phq9_9", 0) > 0 or a.get("death_thoughts") == 1 or c.risk_lower_bound == "high":
return await urgent(ctx, row, code="suicide_signal")
if c.positive and policy.cssrs_positive_policy == "stop_any":
return await urgent(ctx, row, code="cssrs_positive")
if c.conflicts:
return await manual_review(ctx, row, "cssrs_conflict")
# Finish the risk questions before any routine question; a moderate result stops here.
if c.complete and c.risk == "moderate":
return await manual_review(ctx, row, "urgent_psychiatrist")
if c.complete and c.risk == "low":
profile = await ctx.profile()
if profile:
profile.enhanced_monitoring = True
await service.alert(
ctx.session,
ctx.token,
row.id + ":cssrs_low",
"cssrs_low_safety_plan",
ctx.now,
params={"record_id": row.id},
)
if row.kind == "cssrs_since_contact":
# Recent and Since Last Contact are different instruments. No silent substitution.
return await manual_review(ctx, row, "cssrs_since_contact_required")
try:
q = next_question(row)
# Validate preserved short answers against a newly answered full-form item 1.
score_alcohol(group_answers(a, "rus_audit"), short=False, sex=row.details.get("sex"))
except ValueError:
return await manual_review(ctx, row, "answer_conflict")
if q:
replies = [question_reply(row, q)]
if c.complete and c.risk == "low" and not row.details.get("plan_shown"):
from app.dialog.actions import safety_plan_reply
replies.insert(0, await safety_plan_reply(ctx))
row.details = {**row.details, "plan_shown": True}
return Step(replies=replies, next_state="intake.collect", data={"questionnaire_record": row.id})
extra = {}
if a.get("side_effects") == 1:
await service.alert(
ctx.session,
ctx.token,
row.id + ":side_effects_review",
"side_effects_review",
ctx.now,
params={"record_id": row.id},
)
if row.kind == "intake":
result = service.decision(row)
if result.status != "ready":
return await manual_review(ctx, row, "intake_incomplete_review")
extra = {"decision": asdict(result), "totals": service.summary(row)}
if a.get("recent_trauma") == 1:
extra["recent_trauma"] = True
if result.primary_route != "self_help" or result.flags or result.parallel_routes or a.get("recent_trauma"):
await service.alert(
ctx.session,
ctx.token,
row.id + ":route",
"intake_route_review",
ctx.now,
params={"record_id": row.id, **extra},
)
elif row.kind == "diary":
history = {
r.day: r.answers
for r in await service.records(ctx.session, ctx.token)
if r.kind == "diary" and r.status == "complete"
}
history[row.day] = a
flags = diary_flags(history, row.day)
extra["flags"] = flags
for flag in flags:
await service.alert(
ctx.session, ctx.token, row.id + ":" + flag, flag, ctx.now, params={"record_id": row.id}
)
if flag == "phq9_due_mood":
await service.queue_task(ctx.session, ctx.token, "phq9", row.id + ":phq9", ctx.now)
else:
extra["totals"] = service.summary(row)
if row.kind in ("phq9", "pcl5"):
sid = row.kind
total = extra["totals"][sid]["total"]
priority = getattr(policy, sid + "_priority_at")
specialist = getattr(policy, sid + "_specialist_at")
extra["route"] = (
"priority_psychiatrist"
if total >= priority
else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician")
)
prior = next(
(
r
for r in reversed(await service.records(ctx.session, ctx.token))
if r.id != row.id
and r.status == "complete"
and r.version == row.version
and r.details.get("totals", {}).get(sid, {}).get("complete")
and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False))
),
None,
)
if prior:
change = total - prior.details["totals"][sid]["total"]
extra["delta"] = {
"baseline_record": prior.id,
"value": change,
"retriage": change >= getattr(load_runtime(), sid + "_retriage_delta"),
}
if row.kind == "rus_audit_s" and not row.details.get("sex"):
return await manual_review(ctx, row, "alcohol_sex_unknown")
if extra["totals"]["rus_audit"].get("zone") == 4:
extra["parallel_routes"] = ["narcologist"]
retriage = extra.get("delta", {}).get("retriage", False)
await service.alert(
ctx.session,
ctx.token,
row.id + ":followup",
"followup_retriage_required" if retriage else "followup_review",
ctx.now,
level=AlertLevel.HIGH if retriage or extra.get("route") == "priority_psychiatrist" else AlertLevel.MODERATE,
params={"record_id": row.id, **extra},
)
await service.finish(ctx, row, "complete", extra=extra)
from app.dialog.flows.core import menu_reply
replies = [menu_reply(ctx, texts()["done"])]
alcohol_total = extra.get("totals", {}).get("rus_audit", {})
if alcohol_total.get("zone") in (2, 3):
replies.insert(0, Reply(text=texts()["alcohol_advice"], kind="info"))
return Step(replies=replies, next_state="idle", data={"questionnaire_record": None})
async def start(ctx, kind="intake", task=None):
if not allowed(ctx) or crisis_open(ctx.data):
if task:
return Step()
return Step(replies=[Reply(text=texts()["unavailable"], kind="info")])
existing = await service.records(ctx.session, ctx.token)
active = next((r for r in reversed(existing) if r.status in ("active", "paused")), None)
if active:
if task and task.record_id != active.id:
return Step()
if (
active.policy_version != load_policy().version
or active.version != content()["version"]
or active.details.get("runtime_version") != load_runtime().version
):
return await manual_review(ctx, active, "questionnaire_version_changed")
active.status = "active"
return await advance(ctx, active)
if ctx.data.get("intake_review_required") and kind != "cssrs_since_contact":
if task:
return Step()
return Step(replies=[Reply(text=texts()["review"], kind="info")])
row = await service.create_record(ctx, kind, task=task)
return await advance(ctx, row)
@on_enter("intake.start")
async def enter(ctx):
return await start(ctx)
@trigger("button:intake:start")
@trigger("button:intake:resume")
async def start_button(ctx, event):
return await start(ctx)
@trigger("button:menu:checkin", replace=True)
@trigger("system:daily_checkin", replace=True)
async def checkin(ctx, event):
if not intake_enabled():
return Step(replies=[Reply(text=ctx.texts.idle.stub_checkin)])
if event.params.get("questionnaire_task"):
task = await ctx.session.get(QuestionnaireTask, event.params["questionnaire_task"])
if task is None or task.token != ctx.token or task.status not in ("pending", "active"):
return Step()
return await start(ctx, task.kind, task)
existing = await service.records(ctx.session, ctx.token)
if not any(r.kind == "intake" and r.status == "complete" for r in existing):
return await start(ctx)
day = ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date()
if any(r.kind == "diary" and r.day == day and r.status == "complete" for r in existing):
return Step(replies=[Reply(text=texts()["done"], kind="info")])
risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now)
if risk_task.status in ("pending", "active"):
await service.queue_task(ctx.session, ctx.token, "diary", f"diary:{day}", ctx.now)
return await start(ctx, "cssrs_since_contact", risk_task)
return await start(ctx, "diary")
@state("intake")
async def collect(ctx, event: Event):
if not allowed(ctx):
return Step(replies=[Reply(text=texts()["unavailable"], kind="info")], next_state="idle")
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
if row is None or row.token != ctx.token or row.status != "active":
return Step(next_state="idle")
if (
row.policy_version != load_policy().version
or row.version != content()["version"]
or row.details.get("runtime_version") != load_runtime().version
):
return await manual_review(ctx, row, "questionnaire_version_changed")
if event.type == "system":
task_id = event.params.get("questionnaire_task")
if task_id and row.details.get("task_id") == task_id:
return await advance(ctx, row)
return Step()
if event.button == "intake:pause":
row.status = "paused"
return Step(
replies=[Reply(text=texts()["paused"], buttons=[[Button(label=texts()["resume"], data="intake:resume")]])],
next_state="idle",
)
q = next_question(row)
if q is None:
return await advance(ctx, row)
prefix = f"iq:{row.id[:12]}:{q.id}:"
value = None
valid = False
if event.type == "button" and (event.button or "").startswith(prefix):
index = event.button[len(prefix) :]
if index == "skip" and q.optional:
valid = True
elif index.isdigit() and int(index) < len(q.options):
value, valid = q.options[int(index)][0], True
elif event.type == "text" and q.numeric:
try:
value = float(str(ctx.text).replace(",", "."))
valid = math.isfinite(value) and 0 <= value <= 24
except ValueError:
pass
elif event.type == "text" and q.optional:
value, valid = str(ctx.text)[:2000], True
if not valid:
return Step(replies=[Reply(text=texts()["numeric" if q.numeric else "invalid"]), question_reply(row, q)])
service.save_answer(row, q, value, ctx.now)
return await advance(ctx, row)
+465
View File
@@ -0,0 +1,465 @@
"""Medication flow (#37, case scenario «управление приёмом лекарств»). Module-agnostic: every patient with a
prescription gets it, whatever the clinical module.
system:med_reminder {dose_id, drug, dose}
──▶ «Принял» → dose taken, check-in med_taken = true
──▶ «Пропущу» → dose skipped, med_taken = false ──▶ reason:
забыл / побочки (→ side effects) / решил бросить (quit_intent) / другое
──▶ «Есть побочные эффекты» → code buttons (тошнота, бессонница, …) or «Другое» → free text
→ check-in side_effects (+ dose.side_effects) → «А препарат сегодня приняли?»
menu «Вопрос о лекарстве" ──▶ meds.question (free text) ──▶ med_qa (instruction excerpt) or «уточните у врача»
rule action med_info (actions.py hook) ──▶ instruction excerpt for the rule topic + fixed notice
Everything the patient answers is written to the clinical DB (`doses`, `daily_checkins`) and the step asks the
engine to run monitoring (`notify_monitoring`): the rules (lane B, ADR-007) decide alerts and further actions
(quit intent → delayed-onset explanation, missed doses → caring contact). Nothing here decides risk.
Reminder buttons carry the dose they refer to (#56): "med:taken:<ref>", "med:skip:<ref>",
"med:side_effects:<ref>" and the follow-up buttons likewise ("med:se:<code>:<ref>", "med:skip_reason:<r>:<ref>",
"med:dose:yes:<ref>"), matched by prefix triggers. `<ref>` is the `doses.id` of the reminder or, when there is
no dose row (prescription only known to the МИС), the reminder's ISO date. A button of an earlier day's dose is
stale (friendly note, nothing recorded); so are the old payload-less buttons ("med:taken") of earlier releases.
Check-in answers without a button (daily.med step, free text) go to today's planned dose, if any.
"""
from datetime import date, timedelta
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict
from sqlalchemy import select
from app.config import get_settings
from app.core.privacy import scrub
from app.core.rules import Action
from app.db import repo
from app.db.clinical import DailyCheckin, Dose
from app.db.clinical import Prescription as PrescriptionRow
from app.dialog.actions import register_med_info_handler
from app.dialog.contracts import Button, Event, Reply
from app.dialog.flows.core import menu_reply
from app.dialog.fsm import Ctx, Step, abandonable, get_trigger, handler_for, state, trigger, trigger_payload
from app.integrations.base import integrations
from app.llm.med_qa import fallback_text, med_qa
QUESTION_STATE = "meds.question"
SIDE_EFFECT_TEXT_STATE = "meds.side_effect_text"
SIDE_EFFECT_CODES = ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")
SKIP_REASONS = ("forgot", "side_effects", "quit", "other")
# ------------------------------------------------------------------------------------------- texts
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class ReminderTexts(_Strict):
text: str
text_no_drug: str
taken: str
skip: str
side_effects: str
class SkipTexts(_Strict):
question: str
reasons: dict[str, str]
ack: dict[str, str]
class SideEffectTexts(_Strict):
question: str
options: dict[str, str]
questions: dict[str, str]
describe: str
ack: str
repeat: str
ask_dose: str
dose_yes: str
dose_no: str
class CheckinMedTexts(_Strict):
question: str
question_no_drug: str
yes: str
no: str
later: str
class QuestionTexts(_Strict):
prompt: str
no_prescription: str
class MedInfoTopic(_Strict):
section: str # med_qa section code (side_effects | onset | alcohol | ...)
question: str
lead: str = ""
fallback: str
notice: str = ""
class MedsTexts(_Strict):
status: str
reminder: ReminderTexts
taken_ack: str
stale: str
skip: SkipTexts
side_effects: SideEffectTexts
checkin: CheckinMedTexts
question: QuestionTexts
med_info: dict[str, MedInfoTopic]
def meds_texts_path() -> Path:
return get_settings().config_dir / "texts" / "meds.yaml"
@lru_cache
def load_meds_texts(path: Path | None = None) -> MedsTexts:
path = path or meds_texts_path()
return MedsTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
# ------------------------------------------------------------------------------------------- data helpers
class Rx(BaseModel):
"""Active prescription as the dialog needs it (drug INN + dose exactly as the doctor wrote them)."""
id: int | None
drug: str
dose: str
async def current_prescription(ctx: Ctx) -> Rx | None:
"""Latest prescription active today: clinical DB first, then the МИС integration (demo: in-memory)."""
today = ctx.now.date()
rows = await ctx.session.scalars(
select(PrescriptionRow).where(PrescriptionRow.token == ctx.token).order_by(PrescriptionRow.id.desc())
)
for row in rows:
if (row.start_date is None or row.start_date <= today) and (row.end_date is None or row.end_date >= today):
return Rx(id=row.id, drug=row.drug, dose=row.dose)
for p in await integrations.prescriptions.get_prescriptions(ctx.token):
if (p.start_date is None or p.start_date <= today) and (p.end_date is None or p.end_date >= today):
return Rx(id=None, drug=p.drug, dose=p.dose)
return None
async def checkin_of(ctx: Ctx, day: date) -> DailyCheckin | None:
return await ctx.session.scalar(
select(DailyCheckin).where(DailyCheckin.token == ctx.token, DailyCheckin.day == day)
)
async def save_checkin(ctx: Ctx, **fields: Any) -> DailyCheckin:
return await repo.upsert_checkin(ctx.session, token=ctx.token, day=ctx.now.date(), now=ctx.now, **fields)
def dose_ref(dose_id: int | None, day: date) -> str:
"""Button payload naming a dose: its row id, or the reminder day when there is no row."""
return str(dose_id) if dose_id is not None else day.isoformat()
async def todays_dose(ctx: Ctx) -> Dose | None:
"""Latest dose row planned for today (local day of `ctx.now`) — target of answers given without a button."""
rows = await ctx.session.scalars(select(Dose).where(Dose.token == ctx.token).order_by(Dose.id.desc()).limit(20))
today = ctx.now.date()
return next((r for r in rows if r.planned_at.astimezone(ctx.now.tzinfo).date() == today), None)
async def resolve_ref(ctx: Ctx, ref: str | None) -> tuple[bool, Dose | None]:
"""(fresh, dose row) for a button payload. Not fresh: missing/foreign/unknown ref or a dose of another day."""
if not ref:
return False, None
if ref.isdigit():
row = await ctx.session.get(Dose, int(ref))
if row is None or row.token != ctx.token:
return False, None
return row.planned_at.astimezone(ctx.now.tzinfo).date() == ctx.now.date(), row
try:
return date.fromisoformat(ref) == ctx.now.date(), None
except ValueError:
return False, None
async def _dose_row(ctx: Ctx, ref: str | None) -> Dose | None:
if ref is None:
return await todays_dose(ctx)
return (await resolve_ref(ctx, ref))[1]
async def record_dose(ctx: Ctx, taken: bool, *, status: str | None = None, ref: str | None = None) -> None:
"""Dose answer → `doses` row (`ref` from the button, else today's) + `daily_checkins.med_taken` (rules read
both)."""
row = await _dose_row(ctx, ref)
if row is not None:
row.status = status or ("taken" if taken else "skipped")
row.answered_at = ctx.now
await save_checkin(ctx, med_taken=taken)
async def record_side_effects(ctx: Ctx, codes: list[str], *, ref: str | None = None) -> None:
"""Merge side-effect codes into today's check-in (rule `side_effect_3d` counts days with any code)."""
today = await checkin_of(ctx, ctx.now.date())
merged = list(dict.fromkeys([*(today.side_effects if today and today.side_effects else []), *codes]))
await save_checkin(ctx, side_effects=merged)
row = await _dose_row(ctx, ref)
if row is not None:
row.side_effects = ",".join(c for c in dict.fromkeys([*(row.side_effects or "").split(","), *codes]) if c)
async def side_effect_reply(ctx: Ctx, codes: list[str]) -> Reply:
"""Reaction to newly reported side effects.
Same effect as yesterday → short support (the instruction was already shown; persistence over 3 days is the
rules' business). New effect with a known instruction question → the excerpt of the drug instruction
(med_qa, section side_effects) — never generated advice. Otherwise a plain acknowledgement.
"""
t = load_meds_texts().side_effects
yesterday = await checkin_of(ctx, ctx.now.date() - timedelta(days=1))
if yesterday is not None and set(codes) & set(yesterday.side_effects or []):
return Reply(text=t.repeat, kind="info")
rx = await current_prescription(ctx)
for code in codes:
if code in t.questions and rx is not None:
answer = await med_qa(rx.drug, scrub(t.questions[code]), topic="side_effects")
if answer is not None:
return Reply(text=answer.text, kind="info")
return Reply(text=t.ack, kind="info")
async def leave_to_idle(ctx: Ctx, event: Event, data: dict[str, Any] | None = None) -> Step:
"""Abandon the current sub-flow and let the idle state handle the event (menu button, system event...)."""
step = Step(next_state="idle", data=data or {})
idle = handler_for("idle")
return step.then(await idle(ctx, event)) if idle is not None else step
async def run_trigger_inline(ctx: Ctx, key: str, event: Event) -> Step | None:
"""Run an idle trigger from inside another flow (e.g. a reminder button pressed during the check-in)."""
handler = get_trigger(key)
return await handler(ctx, event) if handler is not None else None
# ------------------------------------------------------------------------------------------- reminder
def _reminder_buttons(ref: str) -> list[list[Button]]:
t = load_meds_texts().reminder
return [
[Button(label=t.taken, data=f"med:taken:{ref}")],
[Button(label=t.skip, data=f"med:skip:{ref}")],
[Button(label=t.side_effects, data=f"med:side_effects:{ref}")],
]
@trigger("system:med_reminder", replace=True)
async def _med_reminder(ctx: Ctx, event: Event) -> Step:
t = load_meds_texts().reminder
rx = await current_prescription(ctx)
drug = str(event.params.get("drug") or (rx.drug if rx else ""))
dose = str(event.params.get("dose") or (rx.dose if rx else ""))
try:
dose_id: int | None = int(event.params["dose_id"]) # type: ignore[call-overload]
except (KeyError, TypeError, ValueError):
dose_id = None
if dose_id is None and rx is not None and rx.id is not None:
# No scheduler row (demo console without #13): create the planned dose so the answer has a record.
row = Dose(token=ctx.token, prescription_id=rx.id, planned_at=ctx.now, status="pending")
ctx.session.add(row)
await ctx.session.flush()
dose_id = row.id
text = t.text.format(drug=drug, dose=dose).replace(" ", " ") if drug else t.text_no_drug
reply = Reply(text=text, buttons=_reminder_buttons(dose_ref(dose_id, ctx.now.date())), kind="question")
return Step(replies=[reply], data={"med_pending": None}) # key of earlier releases: the button carries it now
def _stale_reply() -> Step:
return Step(replies=[Reply(text=load_meds_texts().stale)])
async def _legacy(ctx: Ctx, event: Event) -> Step:
"""Payload-less medication button of an earlier release: the dose is unknown → treat as stale."""
return _stale_reply()
def _with_dose(fn: Any) -> Any:
"""Prefix-trigger handler wrapper: resolve the dose payload, answer stale buttons without recording."""
async def handler(ctx: Ctx, event: Event) -> Step:
ref = trigger_payload(event)
fresh, _row = await resolve_ref(ctx, ref)
if not fresh:
return _stale_reply()
return await fn(ctx, event, ref)
return handler
def med_trigger(name: str) -> Any:
"""Register `button:med:<name>:*` (with the dose payload) plus the legacy payload-less key as stale."""
def deco(fn: Any) -> Any:
trigger(f"button:med:{name}:*")(_with_dose(fn))
trigger(f"button:med:{name}")(_legacy)
return fn
return deco
@med_trigger("taken")
async def _taken(ctx: Ctx, event: Event, ref: str) -> Step:
await record_dose(ctx, True, ref=ref)
return Step(replies=[Reply(text=load_meds_texts().taken_ack)], notify_monitoring=True)
@med_trigger("skip")
async def _skip(ctx: Ctx, event: Event, ref: str) -> Step:
await record_dose(ctx, False, status="skipped", ref=ref)
t = load_meds_texts().skip
buttons = [[Button(label=t.reasons[r], data=f"med:skip_reason:{r}:{ref}")] for r in SKIP_REASONS]
return Step(replies=[Reply(text=t.question, buttons=buttons, kind="question")], notify_monitoring=True)
def _side_effect_question(ref: str) -> Reply:
t = load_meds_texts().side_effects
buttons = [[Button(label=t.options[c], data=f"med:se:{c}:{ref}")] for c in SIDE_EFFECT_CODES]
return Reply(text=t.question, buttons=buttons, kind="question")
def _skip_reason(reason: str) -> Any:
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
if reason == "side_effects":
return Step(replies=[_side_effect_question(ref)])
if reason == "quit":
# «Решил бросить» is the same signal as "брошу" in free text: rule `quit_intent` reacts (alert +
# delayed-onset explanation through the med_info action).
await save_checkin(ctx, quit_intent=True)
return Step(replies=[Reply(text=load_meds_texts().skip.ack["quit"])], notify_monitoring=True)
return Step(replies=[Reply(text=load_meds_texts().skip.ack[reason])])
return handler
for _reason in SKIP_REASONS:
med_trigger(f"skip_reason:{_reason}")(_skip_reason(_reason))
@med_trigger("side_effects")
async def _side_effects(ctx: Ctx, event: Event, ref: str) -> Step:
return Step(replies=[_side_effect_question(ref)])
async def _after_side_effects(ctx: Ctx, codes: list[str], ref: str | None) -> Step:
if ref is None:
row = await todays_dose(ctx)
ref = dose_ref(row.id if row is not None else None, ctx.now.date())
await record_side_effects(ctx, codes, ref=ref)
replies = [await side_effect_reply(ctx, codes)]
today = await checkin_of(ctx, ctx.now.date())
if (today is None or today.med_taken is None) and await current_prescription(ctx) is not None:
t = load_meds_texts().side_effects
buttons = [
[Button(label=t.dose_yes, data=f"med:dose:yes:{ref}")],
[Button(label=t.dose_no, data=f"med:dose:no:{ref}")],
]
replies.append(Reply(text=t.ask_dose, buttons=buttons, kind="question"))
return Step(replies=replies, next_state="idle", data={"med_se_ref": None}, notify_monitoring=True)
def _side_effect_code(code: str) -> Any:
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
if code == "other":
return Step(
replies=[Reply(text=load_meds_texts().side_effects.describe)],
next_state=SIDE_EFFECT_TEXT_STATE,
data={"med_se_ref": ref},
)
return await _after_side_effects(ctx, [code], ref)
return handler
for _code in SIDE_EFFECT_CODES:
med_trigger(f"se:{_code}")(_side_effect_code(_code))
@state(SIDE_EFFECT_TEXT_STATE)
async def _side_effect_text(ctx: Ctx, event: Event) -> Step:
if event.type != "text" or ctx.text is None:
return await leave_to_idle(ctx, event, {"med_se_ref": None})
facts = await ctx.gateway.extract_checkin(ctx.text)
# Store only normalised codes, never the description itself (it stays in the scrubbed message log).
codes = [str(c) for c in facts.side_effects] if facts is not None and facts.side_effects else ["other"]
ref = ctx.data.get("med_se_ref")
return await _after_side_effects(ctx, codes, str(ref) if ref else None)
def _dose_answer(taken: bool) -> Any:
async def handler(ctx: Ctx, event: Event, ref: str) -> Step:
await record_dose(ctx, taken, status=None if taken else "missed", ref=ref)
return Step(
replies=[Reply(text=load_meds_texts().taken_ack if taken else load_meds_texts().skip.ack["other"])],
notify_monitoring=True,
)
return handler
med_trigger("dose:yes")(_dose_answer(True))
med_trigger("dose:no")(_dose_answer(False))
# Free-text waiting states of this flow: a long-forgotten question must not swallow tomorrow's message.
abandonable("meds")
# ------------------------------------------------------------------------------------------- question menu
@trigger("button:menu:med_question", replace=True)
async def _menu_question(ctx: Ctx, event: Event) -> Step:
t = load_meds_texts().question
if await current_prescription(ctx) is None:
return Step(replies=[menu_reply(ctx, t.no_prescription)])
return Step(replies=[Reply(text=t.prompt)], next_state=QUESTION_STATE)
async def answer_question(ctx: Ctx, rx: Rx | None) -> str:
"""Answer from the instruction excerpt only (SPEC §7 п.5); dose questions and gaps → «уточните у врача»."""
if rx is None or ctx.text is None:
return load_meds_texts().question.no_prescription
answer = await med_qa(rx.drug, ctx.text)
return answer.text if answer is not None else fallback_text()
@state(QUESTION_STATE)
async def _question(ctx: Ctx, event: Event) -> Step:
if event.type != "text" or ctx.text is None:
return await leave_to_idle(ctx, event)
text = await answer_question(ctx, await current_prescription(ctx))
return Step(replies=[menu_reply(ctx, text)], next_state="idle")
# ------------------------------------------------------------------------------------------- rule actions
async def med_info_reply(ctx: Ctx, action: Action) -> Reply | None:
"""`med_info` action of a rule (quit_intent → onset, alcohol_3d → alcohol): instruction excerpt framed by
fixed texts. The rule already decided to say it; this only picks the words. None → engine template."""
spec = load_meds_texts().med_info.get(str(action.params.get("topic")))
if spec is None:
return None
rx = await current_prescription(ctx)
answer = await med_qa(rx.drug, scrub(spec.question), topic=spec.section) if rx is not None else None
body = answer.text if answer is not None else spec.fallback
return Reply(text="\n\n".join(p for p in (spec.lead, body, spec.notice) if p), kind="info")
register_med_info_handler(med_info_reply)
+465
View File
@@ -0,0 +1,465 @@
"""Daily check-in and scheduled-scale intros (#37, ADR-002, CLINICAL_MODULES §1–2, case «мониторинг после
обращения»).
Check-in = core (mood 0–10, dose taken) + the module question (veteran: sleep hours + nightmares; alcohol for
veterans and for anyone with a positive AUDIT-C) + free text:
system:daily_checkin / menu «Как я сегодня»
──▶ daily.mood buttons 0–10 (a bare number is accepted too)
──▶ daily.med only with a prescription and no dose answer today (the reminder may have got it)
──▶ daily.sleep <4 / 4–6 / 6–8 / >8 h ┐ module veteran
──▶ daily.nightmares yes / no ┘
──▶ daily.alcohol none / 1–2 / more (veteran or AUDIT-C positive)
──▶ daily.text «Как вы? Пару слов» (skip button) → extract_checkin on the SCRUBBED text
──▶ [daily.alcohol_amount] alcohol mentioned in the text but not asked by button → amount buttons
──▶ idle upsert done, `notify_monitoring` → rules (lane B) → dialog actions
Data algorithm (medical data, jury requirement):
* Every button answer is written to `daily_checkins` immediately (`repo.upsert_checkin` merges non-None
fields), so an interrupted check-in (crisis, the patient walking away) still leaves today's mood on record —
the "mood ≤ 3 three days in a row" rule must not miss a day because the free text was never written.
* Button answers are the patient's own and always win over the LLM: from the free text the LLM may only add
side effects, the quit intent and fields that were not asked by buttons today.
* The LLM never decides anything here: alerts and follow-ups (delayed-onset explanation, unscheduled PHQ-9,
alcohol interaction, visit offer) come from the rules through `MonitoringService` (ADR-007).
* A free-text message classified POSSIBLE by the safety layer is taken over by the engine (C-SSRS). When the
screen returns to `daily.text` the message counts as the answer: the check-in is closed, not asked again.
* A check-in left unfinished is dropped on the next event of a later day, or after
`settings.abandon_after_hours` of silence (engine abandon timeout, #56) — answers given so far stay stored.
Also here: friendly intros for scheduled scales (pcl5, phq9, dar5, auditc), registered with
`register_scale_intro` so the engine's `scale_due` / rule-triggered sessions show them.
"""
import re
from datetime import datetime
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict
from app.config import get_settings
from app.core.intake_runtime import intake_enabled
from app.core.rules import load_rules
from app.db import repo
from app.dialog.contracts import Button, Event, Reply
from app.dialog.flows import intake
from app.dialog.flows.core import menu_reply
from app.dialog.flows.meds import (
answer_question,
checkin_of,
current_prescription,
leave_to_idle,
load_meds_texts,
record_dose,
record_side_effects,
run_trigger_inline,
save_checkin,
side_effect_reply,
)
from app.dialog.fsm import Ctx, Step, abandonable, on_enter, state, trigger
from app.dialog.scale_session import register_scale_intro
PREFIX = "daily"
STEPS = ("mood", "med", "sleep", "nightmares", "alcohol", "text")
ALCOHOL_AMOUNT = "alcohol_amount"
SLEEP_BUCKETS = ("<4", "4-6", "6-8", ">8")
ALCOHOL_BUCKETS = ("none", "1-2", "more")
_NUMBER = re.compile(r"^\s*(\d{1,2})\s*$")
# ------------------------------------------------------------------------------------------- texts
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class DailySettings(_Strict):
sleep_modules: list[str]
alcohol_daily_modules: list[str]
intro_scales: list[str]
class Question(_Strict):
question: str
class OptionsQuestion(_Strict):
question: str
options: dict[str, str]
class YesNoQuestion(_Strict):
question: str
yes: str
no: str
class AlcoholQuestion(_Strict):
question: str
clarify: str
options: dict[str, str]
class TextQuestion(_Strict):
question: str
skip: str
class CheckinTexts(_Strict):
start: str
mood: Question
sleep: OptionsQuestion
nightmares: YesNoQuestion
alcohol: AlcoholQuestion
text: TextQuestion
buttons_hint: str
done: str
already_done: str
class VisitTexts(_Strict):
requested: str
class ScaleIntros(_Strict):
scheduled: dict[str, str]
unscheduled: dict[str, str]
class DailyTexts(_Strict):
status: str
settings: DailySettings
checkin: CheckinTexts
visit: VisitTexts
scale_intros: ScaleIntros
def daily_texts_path() -> Path:
return get_settings().config_dir / "texts" / "veteran_daily.yaml"
@lru_cache
def load_daily_texts(path: Path | None = None) -> DailyTexts:
path = path or daily_texts_path()
return DailyTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
# ------------------------------------------------------------------------------------------- step logic
def _today(ctx: Ctx) -> str:
return ctx.now.date().isoformat()
def _ck(ctx: Ctx) -> dict[str, Any]:
return dict(ctx.data.get("checkin") or {})
async def _modules(ctx: Ctx) -> set[str]:
profile = await ctx.profile()
return set(profile.modules or []) if profile is not None else set()
async def _auditc_positive(ctx: Ctx) -> bool:
"""Latest AUDIT-C at or above the sex-specific cut-off of rule `auditc_positive` (one threshold, one place)."""
results = await repo.list_scale_results(ctx.session, ctx.token, "auditc")
if not results:
return False
rule = next((r for r in load_rules().rules if r.id == "auditc_positive"), None)
if rule is None:
return False
profile = await ctx.profile()
key = "threshold_f" if profile is not None and profile.sex == "f" else "threshold_m"
return results[-1].total >= int(rule.params[key])
async def _applicable(ctx: Ctx, step: str) -> bool:
settings = load_daily_texts().settings
if step in ("mood", "text"):
return True
if step == "med":
today = await checkin_of(ctx, ctx.now.date())
return (today is None or today.med_taken is None) and await current_prescription(ctx) is not None
modules = await _modules(ctx)
if step in ("sleep", "nightmares"):
return bool(modules & set(settings.sleep_modules))
if step == "alcohol":
return bool(modules & set(settings.alcohol_daily_modules)) or await _auditc_positive(ctx)
return False
async def _next_step(ctx: Ctx, after: str | None) -> str:
rest = STEPS if after is None else STEPS[STEPS.index(after) + 1 :]
for step in rest:
if await _applicable(ctx, step):
return step
return "text"
def _buttons(step: str, options: list[tuple[str, str]]) -> list[list[Button]]:
return [[Button(label=label, data=f"{PREFIX}:{step}:{value}")] for value, label in options]
async def _question(ctx: Ctx, step: str, note: str = "") -> Reply:
t = load_daily_texts().checkin
buttons: list[list[Button]]
if step == "mood":
text = t.mood.question
# Two rows (0–5, 6–10): 11 buttons in one column are hard to use on a phone.
buttons = [
[Button(label=str(v), data=f"{PREFIX}:mood:{v}") for v in range(6)],
[Button(label=str(v), data=f"{PREFIX}:mood:{v}") for v in range(6, 11)],
]
elif step == "med":
m = load_meds_texts().checkin
rx = await current_prescription(ctx)
text = m.question.format(drug=rx.drug) if rx is not None else m.question_no_drug
buttons = _buttons(step, [("yes", m.yes), ("no", m.no), ("later", m.later)])
elif step == "sleep":
text = t.sleep.question
buttons = _buttons(step, [(b, t.sleep.options[b]) for b in SLEEP_BUCKETS])
elif step == "nightmares":
text = t.nightmares.question
buttons = _buttons(step, [("yes", t.nightmares.yes), ("no", t.nightmares.no)])
elif step in ("alcohol", ALCOHOL_AMOUNT):
text = t.alcohol.question if step == "alcohol" else t.alcohol.clarify
buttons = _buttons(step, [(b, t.alcohol.options[b]) for b in ALCOHOL_BUCKETS])
else:
text = t.text.question
buttons = _buttons("text", [("skip", t.text.skip)])
return Reply(text="\n\n".join(p for p in (note, text) if p), buttons=buttons, kind="question")
async def start_checkin(ctx: Ctx, *, from_menu: bool) -> Step:
if ctx.data.get("checkin_done") == _today(ctx):
# The scheduler may fire twice; the menu button gets a friendly note instead of a second check-in.
return Step(replies=[menu_reply(ctx, load_daily_texts().checkin.already_done)]) if from_menu else Step()
first = await _next_step(ctx, None)
ck = {"day": _today(ctx), "answered": [], "started_at": ctx.now.isoformat()}
return Step(
replies=[Reply(text=load_daily_texts().checkin.start)],
next_state=f"{PREFIX}.{first}",
data={"checkin": ck},
)
def _finish(ctx: Ctx, replies: list[Reply] | None = None) -> Step:
done = Reply(text=load_daily_texts().checkin.done)
return Step(
replies=[done, *(replies or [])],
next_state="idle",
data={"checkin": None, "checkin_done": _today(ctx)},
notify_monitoring=True,
)
def _consumed_by_safety(ctx: Ctx, ck: dict[str, Any]) -> bool:
"""The free-text answer was taken over by the safety layer (C-SSRS screen) after the question was asked."""
asked = ck.get("text_asked_at")
if not asked:
return False
for event in reversed(ctx.data.get("safety_events") or []):
if event.get("state") == f"{PREFIX}.text" and datetime.fromisoformat(event["ts"]) >= datetime.fromisoformat(
asked
):
return True
return False
@on_enter(PREFIX)
async def _enter(ctx: Ctx) -> Step:
step = ctx.state.split(".", 1)[1] if "." in ctx.state else ""
ck = _ck(ctx)
if not ck or ck.get("day") != _today(ctx):
return Step(next_state="idle", data={"checkin": None})
if step == "text":
if _consumed_by_safety(ctx, ck):
return _finish(ctx)
return Step(
replies=[await _question(ctx, step)], data={"checkin": {**ck, "text_asked_at": ctx.now.isoformat()}}
)
return Step(replies=[await _question(ctx, step)])
async def _advance(ctx: Ctx, step: str, ck: dict[str, Any]) -> Step:
ck = {**ck, "answered": [*ck.get("answered", []), step]}
if step == ALCOHOL_AMOUNT:
return _finish(ctx)
nxt = await _next_step(ctx, step)
return Step(next_state=f"{PREFIX}.{nxt}", data={"checkin": ck})
async def _record_button(ctx: Ctx, step: str, value: str) -> bool:
"""Store one button answer right away. False = value not valid for this step (stale/forged button)."""
if step == "mood" and value.isdigit() and 0 <= int(value) <= 10:
await save_checkin(ctx, mood=int(value))
elif step == "med" and value in ("yes", "no", "later"):
if value != "later": # «Ещё нет» is not a missed dose: nothing is recorded yet
await record_dose(ctx, value == "yes", status=None if value == "yes" else "missed")
elif step == "sleep" and value in SLEEP_BUCKETS:
await save_checkin(ctx, sleep_bucket=value)
elif step == "nightmares" and value in ("yes", "no"):
await save_checkin(ctx, nightmares=value == "yes")
elif step in ("alcohol", ALCOHOL_AMOUNT) and value in ALCOHOL_BUCKETS:
await save_checkin(ctx, alcohol_bucket=value)
else:
return False
return True
async def _free_text(ctx: Ctx, ck: dict[str, Any]) -> Step:
assert ctx.text is not None
facts = await ctx.gateway.extract_checkin(ctx.text)
answered = set(ck.get("answered", []))
fields: dict[str, Any] = {"free_text_scrubbed": ctx.text}
replies: list[Reply] = []
clarify_alcohol = False
if facts is not None:
if facts.quit_intent:
fields["quit_intent"] = True
# Buttons win: the LLM only fills what was not asked today.
if "med" not in answered and facts.med_taken is not None:
today = await checkin_of(ctx, ctx.now.date())
if today is None or today.med_taken is None:
fields["med_taken"] = facts.med_taken
if "sleep" not in answered and facts.sleep_hours_bucket is not None:
fields["sleep_bucket"] = facts.sleep_hours_bucket
if "nightmares" not in answered and facts.nightmares is not None:
fields["nightmares"] = facts.nightmares
if "alcohol" not in answered and facts.alcohol is not None:
fields["alcohol_bucket"] = facts.alcohol # provisional; the amount buttons below refine it
clarify_alcohol = facts.alcohol != "none"
if facts.side_effects:
await record_side_effects(ctx, [str(c) for c in facts.side_effects])
# One topic per message: when the text also carries a quit intent or alcohol, the rules answer
# those (delayed onset / interaction); the side-effect excerpt would only bury that message.
if not facts.quit_intent and facts.alcohol in (None, "none"):
replies.append(await side_effect_reply(ctx, [str(c) for c in facts.side_effects]))
await save_checkin(ctx, **fields)
if clarify_alcohol:
return Step(replies=replies, next_state=f"{PREFIX}.{ALCOHOL_AMOUNT}", data={"checkin": ck})
return _finish(ctx, replies)
abandonable(PREFIX)
@state(PREFIX)
async def _handle(ctx: Ctx, event: Event) -> Step:
step = ctx.state.split(".", 1)[1] if "." in ctx.state else ""
ck = _ck(ctx)
if not ck or ck.get("day") != _today(ctx):
# Unfinished check-in from an earlier day: drop it and handle the event as idle would.
return await leave_to_idle(ctx, event, {"checkin": None})
if event.type == "button" and event.button:
parts = event.button.split(":")
if len(parts) == 3 and parts[0] == PREFIX and parts[1] == step:
if step == "text" and parts[2] == "skip":
return _finish(ctx)
if await _record_button(ctx, step, parts[2]):
return await _advance(ctx, step, ck)
if parts[0] == "med":
# A reminder button pressed mid check-in: answer it, then repeat the current question.
inline = await run_trigger_inline(ctx, f"button:{event.button}", event)
if inline is not None:
return inline if inline.next_state else inline.then(Step(replies=[await _question(ctx, step)]))
if parts[0] in (PREFIX, "scale"):
return Step(replies=[await _question(ctx, step)]) # stale button of an earlier question
return await leave_to_idle(ctx, event, {"checkin": None})
if event.type == "system" and event.system:
if event.system == "daily_checkin":
return Step(replies=[await _question(ctx, step)])
inline = await run_trigger_inline(ctx, f"system:{event.system}", event)
if inline is None:
return Step()
return inline if inline.next_state else inline.then(Step(replies=[await _question(ctx, step)]))
if event.type == "text" and ctx.text is not None:
if step == "text":
return await _free_text(ctx, ck)
number = _NUMBER.match(ctx.text)
if step == "mood" and number and await _record_button(ctx, "mood", number.group(1)):
return await _advance(ctx, step, ck)
return Step(replies=[await _question(ctx, step, note=load_daily_texts().checkin.buttons_hint)])
return Step(replies=[await _question(ctx, step)])
# ------------------------------------------------------------------------------------------- idle triggers
@trigger("system:daily_checkin", replace=True)
async def _daily_checkin(ctx: Ctx, event: Event) -> Step:
if intake_enabled(): # the intake diary (#53, feature flag) replaces this check-in when released
return await intake.checkin(ctx, event)
return await start_checkin(ctx, from_menu=False)
@trigger("button:menu:checkin", replace=True)
async def _menu_checkin(ctx: Ctx, event: Event) -> Step:
if intake_enabled():
return await intake.checkin(ctx, event)
return await start_checkin(ctx, from_menu=True)
@trigger("button:menu:visit", replace=True)
async def _visit(ctx: Ctx, event: Event) -> Step:
# «Записаться раньше» (rule action suggest_visit): no online booking yet — the request is a fact for lane B
# (#38) and the dashboard, like `contact_requests`. No alert is created here (ADR-007).
requests = [*(ctx.data.get("visit_requests") or []), {"ts": ctx.now.isoformat()}][-20:]
return Step(
replies=[Reply(text=load_daily_texts().visit.requested)],
data={"visit_requests": requests},
notify_monitoring=True,
)
@trigger("text:idle", replace=True)
async def _idle_text(ctx: Ctx, event: Event) -> Step:
"""Free text outside the check-in: drug question → instruction excerpt; side effects / quit intent / alcohol
→ today's check-in (the rules react); otherwise the neutral support phrase with the menu."""
assert ctx.text is not None
facts = await ctx.gateway.extract_checkin(ctx.text)
if facts is None:
return Step(replies=[menu_reply(ctx, await ctx.gateway.phrase("support"))])
fields: dict[str, Any] = {}
if facts.quit_intent:
fields["quit_intent"] = True
if facts.alcohol is not None and facts.alcohol != "none":
today = await checkin_of(ctx, ctx.now.date())
if today is None or today.alcohol_bucket in (None, "none"):
fields["alcohol_bucket"] = facts.alcohol
text: str | None = None
if facts.side_effects:
codes = [str(c) for c in facts.side_effects]
await record_side_effects(ctx, codes)
if not facts.quit_intent:
text = (await side_effect_reply(ctx, codes)).text
if fields:
await save_checkin(ctx, **fields)
if text is None and facts.med_question and not facts.quit_intent:
text = await answer_question(ctx, await current_prescription(ctx))
if text is None:
text = await ctx.gateway.phrase("support")
return Step(replies=[menu_reply(ctx, text)], notify_monitoring=bool(fields or facts.side_effects))
# ------------------------------------------------------------------------------------------- scale intros
def _scale_intro(ctx: Ctx, scale_id: str, scheduled: bool, return_state: str) -> str | None:
"""Friendly intro of a scheduled (or rule-triggered) scale. Onboarding sessions (#36) have their own texts."""
if ".onboarding" in return_state:
return None
intros = load_daily_texts().scale_intros
return (intros.scheduled if scheduled else intros.unscheduled).get(scale_id)
for _scale_id in load_daily_texts().settings.intro_scales:
register_scale_intro(_scale_id, _scale_intro)
+458
View File
@@ -0,0 +1,458 @@
"""Veteran start screening (issue #36, CLINICAL_MODULES §1, ADR-001/002/007/010).
Entered by the core flow after consent + age gate when the profile has module `veteran`:
veteran.onboarding.start intro «что нормально после возвращения» + FAQ ──«Начнём»──▶
veteran.onboarding.exposure trauma exposure (yes / no / decline)
yes ──▶ scale.pcptsd5 ──▶ ┐
no / decline ───────────▶ ┤ (PC-PTSD-5 is not administered; nothing is stored for it)
veteran.onboarding.pcl5 ─┘──▶ scale.pcl5
veteran.onboarding.phq9 ──▶ scale.phq9 (item 9 buttons only; positive → engine crisis path)
veteran.onboarding.auditc sex from profile, asked if unknown ──▶ scale.auditc
veteran.onboarding.weapon weapon access (4 buttons) → facts; access + risk signal → safe-storage block
veteran.onboarding.result plain-language summary, modules enabled in profile → finish_onboarding()
Clinical decisions encoded here:
* PCL-5 is given to every veteran regardless of the exposure answer: the module's start set is PCL-5/PHQ-9/
AUDIT-C (CLINICAL_MODULES §1); PC-PTSD-5 is only a short entry screen and, by its own instructions, is asked
only after a "yes" to trauma exposure (`config/scales/pcptsd5.yaml: notes`).
* Module `depression` is enabled when the PHQ-9 total meets `config/modules.yaml:
veteran.enables_depression_if` (≥ 10). Scores and severity labels are never shown to the patient.
* Safe-storage block (means restriction, one of the best-evidenced suicide-prevention measures) is shown when
the patient reports access to a weapon AND there is a risk signal in this onboarding: a scale flag listed in
`settings.weapon_risk_flags` (PHQ-9 item 9 ≥ 1, C-SSRS, AUDIT-C positive), a safety event (POSSIBLE/ACUTE)
or a crisis opened since the onboarding began. Deterministic, no LLM (ADR-007).
Facts for lane B (`dialog_state.data["facts"]`, merged with whatever else is there):
trauma_exposure True | False | None (declined)
weapon_access True (self / others_or_safe) | False (none) | None (declined)
weapon_access_choice "none" | "self" | "others_or_safe" | "declined"
weapon_access_at ISO timestamp of the answer
weapon_safety_block True when the safe-storage block was shown
`PatientSnapshot.weapon_access` should be built from `facts["weapon_access"]`.
Crisis during onboarding: the engine moves to `crisis` and drops the running scale session. Progress is kept in
`data["veteran_onboarding"]` (`stage`, `since_id`); when the FSM later enters `idle` (doctor acknowledged the
crisis) the onboarding resumes at the stored stage, and scale stages whose result was already stored since the
onboarding began are skipped (`since_id` = last scale_results id before the start).
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict, Field
from app.config import get_settings
from app.core.modules import load_modules
from app.db import repo
from app.db.clinical import ScaleResult
from app.dialog.contracts import Button, Event, Reply
from app.dialog.crisis import crisis_open
from app.dialog.flows.core import finish_onboarding, menu_reply
from app.dialog.fsm import Ctx, Step, on_enter, state, trigger
from app.dialog.scale_session import start_scale
MODULE = "veteran"
PREFIX = "veteran.onboarding"
START = f"{PREFIX}.start"
EXPOSURE = f"{PREFIX}.exposure"
PCL5 = f"{PREFIX}.pcl5"
PHQ9 = f"{PREFIX}.phq9"
AUDITC = f"{PREFIX}.auditc"
WEAPON = f"{PREFIX}.weapon"
RESULT = f"{PREFIX}.result"
BTN = "vonb" # button data namespace: "vonb:<question>:<choice>"
FAQ_KEYS = ("who", "unit", "licences")
# choice code → value of facts["weapon_access"]: storage with others still means the patient owns a weapon.
WEAPON_ACCESS: dict[str, bool | None] = {"none": False, "self": True, "others_or_safe": True, "declined": None}
EXPOSURE_VALUE: dict[str, bool | None] = {"yes": True, "no": False, "declined": None}
SEX_VALUE: dict[str, str | None] = {"m": "m", "f": "f", "declined": None}
# ------------------------------------------------------------------------------------------- texts
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class OnbSettings(_Strict):
weapon_risk_flags: list[str] = Field(default_factory=list)
class IntroTexts(_Strict):
text: str
go: str
repeat: str
resume: str
class ExposureTexts(_Strict):
question: str
answer_yes: str
answer_no: str
decline: str
ack_yes: str
ack_other: str
class SexTexts(_Strict):
question: str
male: str
female: str
decline: str
class WeaponTexts(_Strict):
question: str
no_access: str
self: str
others_or_safe: str
decline: str
ack: str
safety_block_self: str
safety_block_stored: str
safety_block_unknown: str
class ResultTexts(_Strict):
intro: str
calm: str
load: str
ptsd: str
mood: str
alcohol: str
next: str
class FaqEntry(_Strict):
button: str
text: str
class FaqTexts(_Strict):
who: FaqEntry
unit: FaqEntry
licences: FaqEntry
class VeteranOnboardingTexts(_Strict):
status: str
settings: OnbSettings
intro: IntroTexts
buttons_hint: str
exposure: ExposureTexts
sex: SexTexts
weapon: WeaponTexts
result: ResultTexts
faq: FaqTexts
def texts_path() -> Path:
return get_settings().config_dir / "texts" / "veteran_onboarding.yaml"
@lru_cache
def load_texts(path: Path | None = None) -> VeteranOnboardingTexts:
path = path or texts_path()
return VeteranOnboardingTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
# ------------------------------------------------------------------------------------------- helpers
def _progress(ctx: Ctx) -> dict[str, Any]:
return dict(ctx.data.get("veteran_onboarding") or {})
def _at_stage(ctx: Ctx, stage: str) -> dict[str, Any]:
"""Data update remembering the current stage (resume point after a crisis)."""
return {"veteran_onboarding": {**_progress(ctx), "stage": stage}}
def _facts(ctx: Ctx, **updates: Any) -> dict[str, Any]:
return {"facts": {**(ctx.data.get("facts") or {}), **updates}}
def _faq_buttons() -> list[list[Button]]:
faq = load_texts().faq
return [[Button(label=getattr(faq, key).button, data=f"{BTN}:faq:{key}")] for key in FAQ_KEYS]
def _choice(event: Event, question: str, allowed: dict[str, Any]) -> str | None:
if event.type != "button" or not event.button:
return None
parts = event.button.split(":")
if len(parts) == 3 and parts[0] == BTN and parts[1] == question and parts[2] in allowed:
return parts[2]
return None
def _faq_key(event: Event) -> str | None:
if event.type == "button" and event.button and event.button.startswith(f"{BTN}:faq:"):
key = event.button.rsplit(":", 1)[1]
return key if key in FAQ_KEYS else None
return None
async def _results_since(ctx: Ctx) -> list[ScaleResult]:
since_id = int(_progress(ctx).get("since_id") or 0)
return [r for r in await repo.list_scale_results(ctx.session, ctx.token) if r.id > since_id]
async def _latest(ctx: Ctx, scale: str) -> ScaleResult | None:
rows = [r for r in await _results_since(ctx) if r.scale == scale]
return rows[-1] if rows else None
async def _risk_signals(ctx: Ctx) -> list[str]:
"""Deterministic risk signals collected during this onboarding (see module docstring)."""
risk_flags = set(load_texts().settings.weapon_risk_flags)
signals = sorted({f for r in await _results_since(ctx) for f in (r.flags or {}) if f in risk_flags})
started = str(_progress(ctx).get("started_at") or "")
events = [
e
for e in ctx.data.get("safety_events") or []
if str(e.get("ts", "")) >= started and e.get("level") in ("possible", "acute")
]
if events:
signals.append("safety_event")
opened_at = str((ctx.data.get("crisis") or {}).get("opened_at") or "")
if crisis_open(ctx.data) or (opened_at and opened_at >= started):
signals.append("crisis")
return signals
async def _scale_stage(ctx: Ctx, stage: str, scale: str, next_stage: str) -> Step:
"""Start `scale` unless its result is already stored in this onboarding (resume after a crisis)."""
if await _latest(ctx, scale) is not None:
return Step(next_state=next_stage, data=_at_stage(ctx, next_stage))
step = await start_scale(ctx, scale, return_state=next_stage, scheduled=True)
step.data.update(_at_stage(ctx, stage))
return step
# ------------------------------------------------------------------------------------------- start
def _intro_reply(text: str) -> Reply:
go = Button(label=load_texts().intro.go, data=f"{BTN}:go:yes")
return Reply(text=text, buttons=[[go], *_faq_buttons()], kind="question")
@on_enter(START)
async def _start_enter(ctx: Ctx) -> Step:
rows = await repo.list_scale_results(ctx.session, ctx.token)
progress = {
"stage": START,
"since_id": max((r.id for r in rows), default=0),
"started_at": ctx.now.isoformat(),
}
return Step(replies=[_intro_reply(load_texts().intro.text)], data={"veteran_onboarding": progress})
# ------------------------------------------------------------------------------------------- questions
def _exposure_reply(note: str = "") -> Reply:
t = load_texts().exposure
buttons = [
[Button(label=t.answer_yes, data=f"{BTN}:exposure:yes")],
[Button(label=t.answer_no, data=f"{BTN}:exposure:no")],
[Button(label=t.decline, data=f"{BTN}:exposure:declined")],
]
return Reply(text="\n\n".join(p for p in (note, t.question) if p), buttons=buttons, kind="question")
def _sex_reply(note: str = "") -> Reply:
t = load_texts().sex
buttons = [
[Button(label=t.male, data=f"{BTN}:sex:m")],
[Button(label=t.female, data=f"{BTN}:sex:f")],
[Button(label=t.decline, data=f"{BTN}:sex:declined")],
]
return Reply(text="\n\n".join(p for p in (note, t.question) if p), buttons=buttons, kind="question")
def _weapon_reply(note: str = "") -> Reply:
t = load_texts().weapon
buttons = [
[Button(label=t.no_access, data=f"{BTN}:weapon:none")],
[Button(label=t.self, data=f"{BTN}:weapon:self")],
[Button(label=t.others_or_safe, data=f"{BTN}:weapon:others_or_safe")],
[Button(label=t.decline, data=f"{BTN}:weapon:declined")],
]
return Reply(text="\n\n".join(p for p in (note, t.question) if p), buttons=buttons, kind="question")
def _current_question(ctx: Ctx, note: str = "") -> Reply:
if ctx.state == EXPOSURE:
return _exposure_reply(note)
if ctx.state == AUDITC:
return _sex_reply(note)
if ctx.state == WEAPON:
return _weapon_reply(note)
t = load_texts().intro
return _intro_reply("\n\n".join(p for p in (note, t.repeat) if p))
@on_enter(EXPOSURE)
async def _exposure_enter(ctx: Ctx) -> Step:
return Step(replies=[_exposure_reply()], data=_at_stage(ctx, EXPOSURE))
@on_enter(PCL5)
async def _pcl5_enter(ctx: Ctx) -> Step:
return await _scale_stage(ctx, PCL5, "pcl5", PHQ9)
@on_enter(PHQ9)
async def _phq9_enter(ctx: Ctx) -> Step:
# Item 9 is buttons-only and a positive answer opens the crisis path inside the scale session (engine).
return await _scale_stage(ctx, PHQ9, "phq9", AUDITC)
@on_enter(AUDITC)
async def _auditc_enter(ctx: Ctx) -> Step:
if await _latest(ctx, "auditc") is not None:
return Step(next_state=WEAPON, data=_at_stage(ctx, WEAPON))
profile = await ctx.profile()
known = profile is not None and profile.sex in ("m", "f")
if known or _progress(ctx).get("sex_asked"):
# Unknown sex after "не хочу отвечать": AUDIT-C uses the more sensitive default threshold.
return await _scale_stage(ctx, AUDITC, "auditc", WEAPON)
return Step(replies=[_sex_reply()], data=_at_stage(ctx, AUDITC))
@on_enter(WEAPON)
async def _weapon_enter(ctx: Ctx) -> Step:
return Step(replies=[_weapon_reply()], data=_at_stage(ctx, WEAPON))
@state(PREFIX)
async def _handle(ctx: Ctx, event: Event) -> Step:
"""Events in the onboarding's own question states (scales are handled by the scale session)."""
t = load_texts()
faq = _faq_key(event)
if faq is not None:
return Step(replies=[Reply(text=getattr(t.faq, faq).text), _current_question(ctx)])
if event.type == "system":
return Step() # reminders do not interrupt the screening
if ctx.state == START:
if _choice(event, "go", {"yes": True}):
return Step(next_state=EXPOSURE)
return Step(replies=[_current_question(ctx)])
if ctx.state == EXPOSURE and (code := _choice(event, "exposure", EXPOSURE_VALUE)):
exposed = EXPOSURE_VALUE[code]
step = Step(data=_facts(ctx, trauma_exposure=exposed))
if exposed:
step.replies.append(Reply(text=t.exposure.ack_yes))
nxt = await start_scale(ctx, "pcptsd5", return_state=PCL5, scheduled=True)
nxt.data.update(_at_stage(ctx, EXPOSURE))
return step.then(nxt)
step.replies.append(Reply(text=t.exposure.ack_other))
return step.then(Step(next_state=PCL5))
if ctx.state == AUDITC and (code := _choice(event, "sex", SEX_VALUE)):
sex = SEX_VALUE[code]
if sex is not None:
await repo.upsert_profile(ctx.session, token=ctx.token, sex=sex)
step = await _scale_stage(ctx, AUDITC, "auditc", WEAPON)
step.data["veteran_onboarding"] = {**step.data["veteran_onboarding"], "sex_asked": True}
return step
if ctx.state == WEAPON and (code := _choice(event, "weapon", WEAPON_ACCESS)):
return await _weapon_answer(ctx, code)
return Step(replies=[_current_question(ctx, t.buttons_hint)])
async def _weapon_answer(ctx: Ctx, code: str) -> Step:
t = load_texts().weapon
access = WEAPON_ACCESS[code]
replies = [Reply(text=t.ack)]
show_block = access is not False and bool(await _risk_signals(ctx))
if show_block:
block = {"self": t.safety_block_self, "others_or_safe": t.safety_block_stored}.get(code, t.safety_block_unknown)
replies = [Reply(text=block, kind="safety_plan")]
data = _facts(
ctx,
weapon_access=access,
weapon_access_choice=code,
weapon_access_at=ctx.now.isoformat(),
weapon_safety_block=show_block,
)
# notify_monitoring: weapon access is a monitoring fact (lane B rule "доступ к оружию → блок безопасности").
return Step(replies=replies, next_state=RESULT, data=data, notify_monitoring=True)
# ------------------------------------------------------------------------------------------- result
def _meets(total: int, op: str, value: int) -> bool:
return total >= value if op == ">=" else total > value
@on_enter(RESULT)
async def _result_enter(ctx: Ctx) -> Step:
t = load_texts().result
pcl5 = await _latest(ctx, "pcl5")
auditc = await _latest(ctx, "auditc")
# Depression module: PHQ-9 total vs. the threshold from config/modules.yaml (veteran.enables_depression_if).
rule = load_modules().modules[MODULE].enables_depression_if
trigger_result = await _latest(ctx, rule.scale) if rule is not None else None
depression = bool(rule and trigger_result and _meets(trigger_result.total, rule.op, rule.value))
profile = await ctx.profile()
modules = list(profile.modules or []) if profile is not None else []
for module in (MODULE, "depression") if depression else (MODULE,):
if module not in modules:
modules.append(module)
await repo.upsert_profile(ctx.session, token=ctx.token, modules=modules)
lines = []
if pcl5 is not None and "pcl5_probable_ptsd" in (pcl5.flags or {}):
lines.append(t.ptsd)
if depression:
lines.append(t.mood)
if auditc is not None and "auditc_positive" in (auditc.flags or {}):
lines.append(t.alcohol)
body = [t.intro, t.load, *lines] if lines else [t.intro, t.calm]
summary = Reply(text="\n".join([*body, "", t.next]), buttons=_faq_buttons())
done = Step(
replies=[summary],
data={"veteran_onboarding": {**_progress(ctx), "stage": None, "done_at": ctx.now.isoformat()}},
notify_monitoring=True,
)
return done.then(await finish_onboarding(ctx, module=MODULE))
# ------------------------------------------------------------------------------------------- resume / FAQ
@on_enter("idle")
async def _resume_after_crisis(ctx: Ctx) -> Step:
"""Back in idle with an unfinished veteran onboarding (it was interrupted by a crisis) → continue it."""
stage = _progress(ctx).get("stage")
if not stage or not str(stage).startswith(PREFIX) or crisis_open(ctx.data) or ctx.data.get("consent") is not True:
return Step()
stage = EXPOSURE if stage == START else str(stage)
return Step(replies=[Reply(text=load_texts().intro.resume)], next_state=stage)
def _faq_trigger(key: str) -> None:
@trigger(f"button:{BTN}:faq:{key}")
async def _faq(ctx: Ctx, event: Event) -> Step:
return Step(replies=[menu_reply(ctx, getattr(load_texts().faq, key).text)])
for _key in FAQ_KEYS:
_faq_trigger(_key)
+236
View File
@@ -0,0 +1,236 @@
"""FSM framework of the dialog engine: context, step result and the state / trigger registries.
States are plain strings, namespaced by module ("veteran.onboarding.pcl5"); core states have short names
("new", "consent", "age_gate", "idle", "crisis", "consent_revoked", ...). A flow module
(`app/dialog/flows/<module>.py`) registers its states at import time — the engine never has to be edited to
add a scenario:
from app.dialog.fsm import Ctx, Step, on_enter, state
from app.dialog.scale_session import start_scale
@on_enter("veteran.onboarding.start") # runs when the FSM *enters* the state
async def _start(ctx: Ctx) -> Step:
return await start_scale(ctx, "pcptsd5", return_state="veteran.onboarding.pcl5")
@state("veteran.onboarding.trauma") # handles events while the FSM *is in* the state
async def _trauma(ctx: Ctx, event: Event) -> Step:
...
return Step(replies=[...], next_state="idle", data={"trauma": True})
Name resolution: an exact registration wins, otherwise the longest registered dotted prefix handles the state
("scale.phq9" → the generic "scale" handler). Triggers (`@trigger("button:menu:checkin")`,
`@trigger("system:daily_checkin")`, `@trigger("text:idle")`) let flows take over what the core `idle` state does
with menu buttons, system events and free text; the core registers defaults, a flow overrides with
`replace=True`.
Prefix triggers carry data in the button (#56): `@trigger("button:med:taken:*")` handles every button whose data
starts with "med:taken:"; the rest of the data ("med:taken:42" → "42") reaches the handler as
`trigger_payload(event)`. An exact key always wins over a prefix, a longer prefix over a shorter one.
Abandoned steps (#56): a flow marks its question states with `abandonable("daily")`; if the patient stays silent
in such a state longer than `settings.abandon_after_hours`, the engine returns the FSM to `idle` before
handling the next event (answers already stored stay stored).
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.types import ScrubbedText
from app.db import repo
from app.db.clinical import PatientProfile
from app.dialog.contracts import Event, Reply
from app.dialog.texts import DialogTexts
from app.llm.gateway import LLMGateway
from app.services.contracts import MonitoringService
# States where nothing is being asked: a pending scale may start here (see engine `_start_pending`).
RESTING_STATES = frozenset({"idle", "crisis"})
# States without (valid) consent: the engine stores nothing and never calls the LLM here (152-FZ art. 9-10).
NO_CONSENT_STATES = frozenset({"new", "consent", "refused", "minor", "consent_revoked"})
@dataclass
class Ctx:
"""Everything a state handler may use. `data` is the persisted `dialog_state.data` (treat as read-only:
return changes in `Step.data`, the engine applies them)."""
token: str
state: str
data: dict[str, Any]
now: datetime # from app.core.clock, taken once per incoming event (ADR-005)
session: AsyncSession # clinical DB unit of work of this event
gateway: LLMGateway
monitoring: MonitoringService
texts: DialogTexts
text: ScrubbedText | None = None # scrubbed patient text for text events — the only text handlers see
_profile: PatientProfile | None = field(default=None, repr=False)
_profile_loaded: bool = field(default=False, repr=False)
async def profile(self) -> PatientProfile | None:
if not self._profile_loaded:
self._profile = await repo.get_profile(self.session, self.token)
self._profile_loaded = True
return self._profile
@dataclass
class Step:
"""Result of a handler.
replies — messages to send, in order;
next_state — new FSM state (None = stay); entering a different state runs its `@on_enter` hook;
data — updates merged into `dialog_state.data` (a value of None deletes the key);
notify_monitoring — the handler wrote clinical data: the engine commits and calls
`MonitoringService.after_data_change()`, then executes the returned dialog actions.
"""
replies: list[Reply] = field(default_factory=list)
next_state: str | None = None
data: dict[str, Any] = field(default_factory=dict)
notify_monitoring: bool = False
def then(self, other: Step) -> Step:
"""Sequential composition: replies concatenated, later state/data win."""
return Step(
replies=[*self.replies, *other.replies],
next_state=other.next_state or self.next_state,
data={**self.data, **other.data},
notify_monitoring=self.notify_monitoring or other.notify_monitoring,
)
Handler = Callable[[Ctx, Event], Awaitable[Step]]
EnterHook = Callable[[Ctx], Awaitable[Step]]
_handlers: dict[str, Handler] = {}
_enter_hooks: dict[str, EnterHook] = {}
_triggers: dict[str, Handler] = {}
_prefix_triggers: dict[str, Handler] = {} # "button:med:taken:" → handler (registered as "button:med:taken:*")
_abandonable: dict[str, Callable[[Ctx], bool] | None] = {}
PREFIX_WILDCARD = "*"
PAYLOAD_PARAM = "trigger_payload"
def state(name: str) -> Callable[[Handler], Handler]:
"""Register the event handler of state `name` (or of a dotted prefix, e.g. "scale")."""
def deco(fn: Handler) -> Handler:
if name in _handlers:
raise ValueError(f"state '{name}' already registered")
_handlers[name] = fn
return fn
return deco
def on_enter(name: str) -> Callable[[EnterHook], EnterHook]:
"""Register a hook that runs when the FSM enters state `name` (asks the first question, chains flows)."""
def deco(fn: EnterHook) -> EnterHook:
if name in _enter_hooks:
raise ValueError(f"enter hook of '{name}' already registered")
_enter_hooks[name] = fn
return fn
return deco
def trigger(key: str, *, replace: bool = False) -> Callable[[Handler], Handler]:
"""Register a handler for an idle-menu button / system event / idle free text.
Keys: "button:<Button.data>", "system:<SystemEventKind>", "text:idle". The core registers defaults;
flows pass `replace=True` to take one over. A key ending in ":*" is a prefix trigger (see module doc).
"""
table, name = _triggers, key
if key.endswith(":" + PREFIX_WILDCARD):
table, name = _prefix_triggers, key[: -len(PREFIX_WILDCARD)]
def deco(fn: Handler) -> Handler:
if name in table and not replace:
raise ValueError(f"trigger '{key}' already registered (use replace=True to override)")
table[name] = fn
return fn
return deco
def trigger_payload(event: Event) -> str | None:
"""Data after the matched prefix of a prefix trigger ("med:taken:42" → "42"); None for exact triggers."""
value = event.params.get(PAYLOAD_PARAM)
return str(value) if value is not None else None
def abandonable(prefix: str, when: Callable[[Ctx], bool] | None = None) -> None:
"""Mark states `prefix` / `prefix.*` as question steps the engine may abandon after a long silence.
`when(ctx)` may veto it for a particular situation (e.g. a scale that is part of an onboarding).
"""
if prefix in _abandonable:
raise ValueError(f"abandonable '{prefix}' already registered")
_abandonable[prefix] = when
def is_abandonable(ctx: Ctx) -> bool:
key = _resolve(ctx.state, _abandonable)
if key is None:
return False
when = _abandonable[key]
return when is None or when(ctx)
def _resolve(name: str, table: dict[str, Any]) -> str | None:
candidate = name
while candidate:
if candidate in table:
return candidate
if "." not in candidate:
return None
candidate = candidate.rsplit(".", 1)[0]
return None
def handler_for(name: str) -> Handler | None:
key = _resolve(name, _handlers)
return _handlers[key] if key else None
def enter_hook_for(name: str) -> EnterHook | None:
key = _resolve(name, _enter_hooks)
return _enter_hooks[key] if key else None
def get_trigger(key: str) -> Handler | None:
"""Exact trigger, else the longest matching prefix trigger (wrapped so it sees the payload)."""
exact = _triggers.get(key)
if exact is not None:
return exact
matches = [p for p in _prefix_triggers if key.startswith(p) and len(key) > len(p)]
if not matches:
return None
prefix = max(matches, key=len)
fn, payload = _prefix_triggers[prefix], key[len(prefix) :]
async def with_payload(ctx: Ctx, event: Event) -> Step:
return await fn(ctx, event.model_copy(update={"params": {**event.params, PAYLOAD_PARAM: payload}}))
return with_payload
def is_registered(name: str) -> bool:
"""Exact registration (handler or enter hook) — used to find a module's onboarding entry point."""
return name in _handlers or name in _enter_hooks
def apply_data(data: dict[str, Any], updates: dict[str, Any]) -> None:
for key, value in updates.items():
if value is None:
data.pop(key, None)
else:
data[key] = value
+288
View File
@@ -0,0 +1,288 @@
"""Reusable scale session: asks a questionnaire item by item inside the dialog (SPEC §6 "Сбор шкалы в диалоге").
Usage from a flow (#36/#37):
step = await start_scale(ctx, "pcl5", return_state="veteran.onboarding.phq9", scheduled=True)
return step # FSM goes to "scale.pcl5"; its enter hook asks item 1; on completion → return_state
Algorithm per item:
1. The item is shown with one button per answer option from `config/scales/<id>.yaml`.
2. A button press is taken as is (source="button").
3. Free text is accepted only for items without `buttons_only` (PHQ-9 item 9 and the whole C-SSRS are
buttons-only: suicidal-ideation answers are never interpreted by the LLM — SPEC §6, ADR-002). The scrubbed
text goes to `gateway.extract_item`; `None` (failure / confidence < 0.5 / out-of-range score) → a clarifying
question with the same buttons, nothing is stored (source="llm" only for a confident extraction).
4. Every answer is stored immediately (`item_responses`), so a session interrupted by a crisis keeps its data.
5. After each answer the scale is scored deterministically (`app.core.scales.score`). If a crisis flag from
`config/texts/dialog.yaml: settings.crisis_flags` fires (PHQ-9 item 9 ≥ 1, C-SSRS high) the session ends at
once: partial result saved, fixed crisis script, C-SSRS queued (unless this *is* the C-SSRS, or a C-SSRS was
completed less than `settings.cssrs_cooldown_hours` ago — the patient is not asked the same suicide screen
twice in a day; the crisis script and the alert still happen). A positive item 9 is acted upon even in a
half-finished questionnaire.
6. On completion: `scale_results` row (total, severity, item 9, scheduled flag, flags) → the engine commits
and calls `MonitoringService.after_data_change()` (rules decide alerts — ADR-007) → back to `return_state`.
The patient never sees a score or a severity label: that would read as a diagnosis.
Intros (#56): `start_scale(intro=None)` looks the intro up in a registry filled by flows
(`register_scale_intro("phq9", fn_or_text)`), so scales started by the engine (`scale_due`, rule actions) get
the flow's friendly intro without the flow hooking `scale.<id>` itself.
"""
from collections.abc import Callable
from dataclasses import replace
from datetime import timedelta
from typing import Any
from app.core.privacy import scrub
from app.core.scales import Item, ScaleDef, ScaleResult, load_scale, score
from app.db import repo
from app.dialog.contracts import Button, Event, Reply
from app.dialog.crisis import crisis_open, open_crisis
from app.dialog.fsm import RESTING_STATES, Ctx, Step, abandonable, is_abandonable, on_enter, state
PREFIX = "scale"
# (ctx, scale_id, scheduled, return_state) → intro text or None (no intro in this situation).
IntroFn = Callable[[Ctx, str, bool, str], str | None]
_intros: dict[str, str | IntroFn] = {}
def register_scale_intro(scale_id: str, intro: str | IntroFn, *, replace: bool = False) -> None:
"""Register the intro shown before `scale_id` when a session is started without an explicit `intro`."""
if scale_id in _intros and not replace:
raise ValueError(f"intro of scale '{scale_id}' already registered (use replace=True to override)")
_intros[scale_id] = intro
def scale_intro(ctx: Ctx, scale_id: str, *, scheduled: bool, return_state: str) -> str | None:
intro = _intros.get(scale_id)
if intro is None or isinstance(intro, str):
return intro or None
return intro(ctx, scale_id, scheduled, return_state) or None
def scale_state(scale_id: str) -> str:
return f"{PREFIX}.{scale_id}"
def active_scale(state_name: str) -> str | None:
return state_name.split(".", 1)[1] if state_name.startswith(PREFIX + ".") else None
def add_pending(data: dict[str, Any], scale_id: str, scheduled: bool) -> list[dict[str, Any]]:
"""Queue a scale to start at the next resting state (idle/crisis); duplicates are ignored."""
pending = list(data.get("pending_scales") or [])
if scale_id not in {p["scale"] for p in pending}:
pending.append({"scale": scale_id, "scheduled": scheduled})
return pending
async def start_scale(
ctx: Ctx, scale_id: str, *, return_state: str, scheduled: bool = True, intro: str | None = None
) -> Step:
"""Begin a session of `scale_id`; returns a Step moving the FSM to `scale.<id>`.
`scheduled=False` marks an unscheduled administration (triggered by a rule or a safety signal).
`intro` — optional extra text shown before the scale instruction; None → the registered intro, if any.
"""
scale = load_scale(scale_id) # fail fast on an unknown id
if intro is None:
intro = scale_intro(ctx, scale_id, scheduled=scheduled, return_state=return_state)
sessions = dict(ctx.data.get("scales") or {})
sessions[scale_id] = {
"idx": 0,
"answers": {},
"return_state": return_state,
"scheduled": scheduled,
"intro": intro,
"intro_shown": False,
}
target = scale_state(scale_id)
if ctx.state == target: # restart in place: the enter hook would not run
sessions[scale_id]["intro_shown"] = True
return Step(replies=[_question(ctx, scale, sessions[scale_id], intro=True)], data={"scales": sessions})
return Step(next_state=target, data={"scales": sessions})
def _question(ctx: Ctx, scale: ScaleDef, session: dict[str, Any], *, intro: bool = False, note: str = "") -> Reply:
idx = session["idx"]
item = scale.items[idx]
parts: list[str] = []
if note:
parts.append(note)
if intro:
if session.get("intro"):
parts.append(session["intro"])
parts.append(ctx.texts.scale.intro.format(count=len(scale.items), instruction=scale.instruction or "").strip())
parts.append(ctx.texts.scale.question.format(n=idx + 1, total=len(scale.items), text=item.text))
buttons = [[Button(label=o.label, data=f"{PREFIX}:{scale.id}:{item.id}:{o.value}")] for o in item.options]
return Reply(text="\n\n".join(parts), buttons=buttons, kind="question")
def _session(ctx: Ctx) -> tuple[ScaleDef, dict[str, Any]] | None:
scale_id = active_scale(ctx.state)
session = (ctx.data.get("scales") or {}).get(scale_id) if scale_id else None
if scale_id is None or session is None:
return None
return load_scale(scale_id), session
def _abandon_allowed(ctx: Ctx) -> bool:
"""A silent scale session may be abandoned only if what it returns to may be (idle, a check-in step) —
never an onboarding chain, which would otherwise be skipped."""
found = _session(ctx)
if found is None:
return True
back = str(found[1].get("return_state") or "idle")
if back in RESTING_STATES:
return True
return back != ctx.state and is_abandonable(replace(ctx, state=back))
abandonable(PREFIX, _abandon_allowed)
async def recent_risk_screen(ctx: Ctx, screen: str) -> bool:
"""A complete risk screen (C-SSRS) less than `cssrs_cooldown_hours` ago (scale_results, any trigger)."""
since = ctx.now - timedelta(hours=ctx.texts.settings.cssrs_cooldown_hours)
results = await repo.list_scale_results(ctx.session, ctx.token, screen)
return any(r.complete and r.ts >= since for r in results)
@on_enter(PREFIX)
async def _enter(ctx: Ctx) -> Step:
found = _session(ctx)
if found is None:
return Step(next_state="idle")
scale, session = found
show_intro = session["idx"] == 0 and not session.get("intro_shown")
updated = {**session, "intro_shown": True}
sessions = {**(ctx.data.get("scales") or {}), scale.id: updated}
return Step(replies=[_question(ctx, scale, updated, intro=show_intro)], data={"scales": sessions})
def _parse_button(data: str, scale: ScaleDef, item: Item) -> int | None:
parts = data.split(":")
if len(parts) != 4 or parts[0] != PREFIX or parts[1] != scale.id or parts[2] != item.id:
return None # stale button from an earlier question
try:
value = int(parts[3])
except ValueError:
return None
return value if value in {o.value for o in item.options} else None
@state(PREFIX)
async def _handle(ctx: Ctx, event: Event) -> Step:
found = _session(ctx)
if found is None:
return Step(next_state="idle")
scale, session = found
item = scale.items[session["idx"]]
if event.type == "button" and event.button:
value = _parse_button(event.button, scale, item)
if value is not None:
return await _record(ctx, scale, session, item, value, source="button")
return Step(replies=[_question(ctx, scale, session)])
if event.type == "text" and ctx.text is not None:
if item.buttons_only:
# Safety items are never LLM-interpreted: ask again for a button.
return Step(replies=[_question(ctx, scale, session, note=ctx.texts.scale.buttons_only)])
extracted = await ctx.gateway.extract_item(item.id, item.text, item.max_value, ctx.text)
if extracted is None or extracted.score not in {o.value for o in item.options}:
return Step(replies=[_question(ctx, scale, session, note=ctx.texts.scale.clarify)])
return await _record(
ctx,
scale,
session,
item,
extracted.score,
source="llm",
confidence=extracted.confidence,
evidence=extracted.evidence,
)
if event.type == "system":
return Step() # reminders etc. do not interrupt a questionnaire
return Step(replies=[_question(ctx, scale, session)])
async def _record(
ctx: Ctx,
scale: ScaleDef,
session: dict[str, Any],
item: Item,
value: int,
*,
source: str,
confidence: float | None = None,
evidence: str = "",
) -> Step:
await repo.add_item_response(
ctx.session,
token=ctx.token,
scale=scale.id,
item_id=item.id,
score=value,
source=source,
ts=ctx.now,
confidence=confidence,
# Evidence is a quote of already scrubbed text; scrub again so nothing unscrubbed can be stored.
evidence=scrub(evidence) if evidence else None,
)
answers = {**session["answers"], item.id: value}
profile = await ctx.profile()
sex = profile.sex if profile is not None and profile.sex in ("m", "f") else None
result = score(scale.id, answers, sex=sex) # type: ignore[arg-type]
crisis_flags = [f for f in result.flags if f in ctx.texts.settings.crisis_flags]
if crisis_flags or session["idx"] + 1 >= len(scale.items):
return await _finish(ctx, scale, session, answers, result, crisis_flags)
updated = {**session, "answers": answers, "idx": session["idx"] + 1}
sessions = {**(ctx.data.get("scales") or {}), scale.id: updated}
return Step(replies=[_question(ctx, scale, updated)], data={"scales": sessions})
async def _finish(
ctx: Ctx,
scale: ScaleDef,
session: dict[str, Any],
answers: dict[str, int],
result: ScaleResult,
crisis_flags: list[str],
) -> Step:
await repo.add_scale_result(
ctx.session,
token=ctx.token,
scale=scale.id,
total=result.total,
severity=result.severity_code,
complete=result.complete,
ts=ctx.now,
# PHQ-9 item 9 is stored separately: any value >= 1 escalates regardless of the total.
item9=answers.get("phq9_9") if scale.id == "phq9" else None,
scheduled=bool(session.get("scheduled", True)),
flags={f: True for f in result.flags},
)
sessions = {k: v for k, v in (ctx.data.get("scales") or {}).items() if k != scale.id}
done = Step(
replies=[Reply(text=ctx.texts.scale.done)],
next_state=session["return_state"],
data={"scales": sessions},
notify_monitoring=True,
)
if not crisis_flags:
return done
# Crisis path decided by the scale's own flags (deterministic), not by the LLM.
risk_screen = ctx.texts.settings.risk_screen_scale
if crisis_open(ctx.data):
crisis = Step(next_state="crisis")
else:
crisis = open_crisis(ctx, categories=[], source=f"scale:{scale.id}")
step = done.then(crisis)
# Same-day repeat guard: a C-SSRS completed within the cooldown already answered the screening question.
if scale.id != risk_screen and not await recent_risk_screen(ctx, risk_screen):
step.data["pending_scales"] = add_pending(ctx.data, risk_screen, scheduled=False)
return step
+122
View File
@@ -0,0 +1,122 @@
"""Patient-facing dialog texts from `config/texts/dialog.yaml` (ADR-008: texts live in config, not code)."""
from functools import lru_cache
from pathlib import Path
import yaml
from pydantic import BaseModel, ConfigDict, Field
from app.config import get_settings
class _Strict(BaseModel):
# A typo in a YAML key must fail at load time, not silently show an empty message to a patient.
model_config = ConfigDict(extra="forbid", frozen=True)
class DialogSettings(_Strict):
cssrs_cooldown_hours: float = 24
max_safety_events: int = 50
crisis_flags: list[str] = Field(default_factory=list)
risk_screen_scale: str = "cssrs_screen"
abandon_after_hours: float = 6
class Commands(_Strict):
revoke: list[str]
class ConsentTexts(_Strict):
version: str
text: str
accept: str
decline: str
refused: str
reconsider: str
code_invalid: str
class AgeGateTexts(_Strict):
question: str
adult: str
minor: str
unclear: str
minor_stop: str
class IdleButtons(_Strict):
checkin: str
med_question: str
contact_doctor: str
revoke: str
class IdleTexts(_Strict):
welcome: str
menu_prompt: str
buttons: IdleButtons
stub_checkin: str
stub_med_question: str
contact_doctor_ack: str
visit_stub: str
class RevokedTexts(_Strict):
confirm: str
class CrisisTexts(_Strict):
repeat: str
hold: str
closed: str
class ScaleTexts(_Strict):
intro: str
question: str
clarify: str
buttons_only: str
done: str
risk_screen_intro: str
class SafetyPlanTexts(_Strict):
title: str
template: list[str]
means_restriction: str
class ReachOutTexts(_Strict):
silence: str
missed_doses: str
default: str
class VisitTexts(_Strict):
button: str
class DialogTexts(_Strict):
status: str
settings: DialogSettings
commands: Commands
consent: ConsentTexts
age_gate: AgeGateTexts
idle: IdleTexts
revoked: RevokedTexts
crisis: CrisisTexts
scale: ScaleTexts
safety_plan: SafetyPlanTexts
reach_out: ReachOutTexts
visit: VisitTexts
unavailable: str
def dialog_texts_path() -> Path:
return get_settings().config_dir / "texts" / "dialog.yaml"
@lru_cache
def load_dialog_texts(path: Path | None = None) -> DialogTexts:
path = path or dialog_texts_path()
return DialogTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
+257
View File
@@ -0,0 +1,257 @@
"""Demo scheduler on the virtual clock (#13, SPEC §8, ADR-005).
`run_until(now)` replays, in time order, every event that is due by `now` and still fresh
(`config/scheduler.yaml`):
* med_reminder — a `doses` row (pending) per prescription time, then `system:med_reminder {dose_id, drug, dose}`;
* missed — a reminded dose still pending `missed_after_hours` later → status `missed` → monitoring;
* daily_checkin — `system:daily_checkin` at the check-in time;
* scale_due — scales due by the module schedules (`config/modules.yaml`) → `system:scale_due`;
* evaluation — once a day `MonitoringService.after_data_change()`: catches silence > N days (rule
`no_contact_3d`) and missed doses even when the patient writes nothing.
The rules decide (ADR-007); the scheduler only turns their dialog actions into system events for the engine
(`reach_out` → `system:reach_out`, `schedule_scale` → `system:scale_due`). Replies go to `integrations.outbox`.
Idempotent: a delivered event is stored in `scheduler_events` under a unique key and never sent again; a failed
delivery is not stored and is retried on the next run while it is fresh (at least once). Only patients with
consent (and adult) are contacted; while a crisis is open no reminders, check-ins or scales are sent.
Time comes only from the arguments / `app.core.clock` (guardrail #4).
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import date, datetime, time, timedelta
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.modules import due_scales, load_modules
from app.core.rules import Action
from app.db import repo
from app.db.clinical import DialogState, Dose, PatientProfile, Prescription
from app.db.scheduler import SchedulerEvent
from app.db.session import get_clinical_session
from app.dialog.contracts import Event, Reply, get_engine
from app.integrations.base import integrations
from app.services.contracts import get_monitoring
log = logging.getLogger(__name__)
# ------------------------------------------------------------------------------------------------ config
class _Strict(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class DosesConfig(_Strict):
late_hours: float = Field(gt=0)
missed_after_hours: float = Field(gt=0)
class Window(_Strict):
time: time
late_hours: float = Field(gt=0)
class SchedulerConfig(_Strict):
enabled: bool = True
tick_seconds: float = Field(gt=0)
doses: DosesConfig
daily_checkin: Window
scales: Window
evaluation: Window
@lru_cache
def load_config(path: Path | None = None) -> SchedulerConfig:
path = path or get_settings().config_dir / "scheduler.yaml"
return SchedulerConfig.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
# ------------------------------------------------------------------------------------------------ jobs
# Same due time → this order: the dose before the check-in that asks about it.
_ORDER = {"evaluation": 0, "missed": 1, "med_reminder": 2, "scale_due": 3, "daily_checkin": 4}
@dataclass(order=True)
class Job:
due: datetime
rank: int
token: str
key: str
kind: str = field(compare=False)
params: dict[str, Any] = field(default_factory=dict, compare=False)
def _job(due: datetime, token: str, key: str, kind: str, **params: Any) -> Job:
return Job(due=due, rank=_ORDER[kind], token=token, key=key, kind=kind, params=params)
def _at(day: date, t: time, now: datetime) -> datetime:
return datetime.combine(day, t, tzinfo=now.tzinfo)
def _fresh(due: datetime, now: datetime, late_hours: float) -> bool:
return due <= now < due + timedelta(hours=late_hours)
def _window(day: date, w: Window, now: datetime) -> datetime | None:
due = _at(day, w.time, now)
return due if _fresh(due, now, w.late_hours) else None
async def _dose_jobs(s: AsyncSession, p: PatientProfile, now: datetime, cfg: SchedulerConfig) -> list[Job]:
jobs: list[Job] = []
today = now.date()
for rx in (await s.scalars(select(Prescription).where(Prescription.token == p.token))).all():
start = rx.start_date or p.therapy_start
for day in (today - timedelta(days=1), today): # a fresh dose is never older than a day
if (start and day < start) or (rx.end_date and day > rx.end_date):
continue
for raw in (rx.schedule or {}).get("times", []):
planned = _at(day, time.fromisoformat(raw), now)
if _fresh(planned, now, cfg.doses.late_hours):
key = f"dose:{rx.id}:{planned.isoformat(timespec='minutes')}"
jobs.append(_job(planned, p.token, key, "med_reminder", rx_id=rx.id, planned_at=planned))
return jobs
async def _missed_jobs(s: AsyncSession, token: str, now: datetime, cfg: SchedulerConfig) -> list[Job]:
after = timedelta(hours=cfg.doses.missed_after_hours)
stmt = select(Dose).where(Dose.token == token, Dose.status == "pending", Dose.planned_at <= now - after)
return [_job(d.planned_at + after, token, f"missed:{d.id}", "missed", dose_id=d.id) for d in await s.scalars(stmt)]
async def _scale_jobs(s: AsyncSession, p: PatientProfile, now: datetime, cfg: SchedulerConfig) -> list[Job]:
due_at = _window(now.date(), cfg.scales, now)
if due_at is None:
return []
last: dict[str, date] = {}
for r in await repo.list_scale_results(s, p.token):
if r.complete:
last[r.scale] = r.ts.astimezone(now.tzinfo).date()
if p.therapy_start:
# A scale never taken counts from the visit where therapy started, not as overdue on day 0.
for mod in load_modules().modules.values():
for entry in mod.schedule:
last.setdefault(entry.scale, p.therapy_start)
therapy_day = (now.date() - p.therapy_start).days if p.therapy_start else 0
scales = due_scales(set(p.modules or []), therapy_day, last, now.date())
return [_job(due_at, p.token, f"scale:{sc}:{now.date()}", "scale_due", scale=sc) for sc in scales]
async def collect(now: datetime, cfg: SchedulerConfig | None = None) -> list[Job]:
"""Due and fresh, not yet delivered events of all contactable patients, in time order."""
cfg = cfg or load_config()
jobs: list[Job] = []
async with get_clinical_session() as s:
for p in (await s.scalars(select(PatientProfile).order_by(PatientProfile.token))).all():
state = await s.get(DialogState, p.token)
data = state.data if state is not None else {}
if data.get("consent") is not True or data.get("adult") is not True:
continue
mine = await _missed_jobs(s, p.token, now, cfg)
if (due := _window(now.date(), cfg.evaluation, now)) is not None:
mine.append(_job(due, p.token, f"eval:{now.date()}", "evaluation"))
if not (data.get("crisis") or {}).get("open"):
mine += await _dose_jobs(s, p, now, cfg)
mine += await _scale_jobs(s, p, now, cfg)
if (due := _window(now.date(), cfg.daily_checkin, now)) is not None:
mine.append(_job(due, p.token, f"checkin:{now.date()}", "daily_checkin"))
done = set((await s.scalars(select(SchedulerEvent.key).where(SchedulerEvent.token == p.token))).all())
jobs += [j for j in mine if j.key not in done]
return sorted(jobs)
# ------------------------------------------------------------------------------------------------ delivery
async def _system(token: str, kind: str, **params: Any) -> None:
replies: list[Reply] = await get_engine().handle(
token,
Event(type="system", system=kind, params=params), # type: ignore[arg-type]
)
if replies:
await integrations.outbox.push(token, replies)
async def _after_rules(token: str) -> None:
"""Monitoring pass outside a dialog: hand the rules' dialog actions to the engine as system events."""
actions: list[Action] = await get_monitoring().after_data_change(token)
for a in actions:
if a.type == "reach_out":
await _system(token, "reach_out", **a.params)
elif a.type == "schedule_scale" and a.params.get("scale"):
await _system(token, "scale_due", scale=a.params["scale"], scheduled=bool(a.params.get("scheduled")))
elif a.type == "crisis":
log.warning("scheduler crisis action token=%s code=%s", token, a.code) # alert already persisted
async def _ensure_dose(token: str, rx_id: int, planned_at: datetime) -> tuple[int, Prescription]:
async with get_clinical_session() as s:
rx = await s.get(Prescription, rx_id)
assert rx is not None
dose = await s.scalar(
select(Dose).where(Dose.token == token, Dose.prescription_id == rx_id, Dose.planned_at == planned_at)
)
if dose is None:
dose = Dose(token=token, prescription_id=rx_id, planned_at=planned_at, status="pending")
s.add(dose)
await s.flush()
return dose.id, rx
async def _mark_missed(dose_id: int) -> bool:
async with get_clinical_session() as s:
dose = await s.get(Dose, dose_id)
if dose is None or dose.status != "pending":
return False # answered meanwhile
dose.status = "missed"
return True
async def _run(job: Job, now: datetime) -> None:
if job.kind == "med_reminder":
dose_id, rx = await _ensure_dose(job.token, job.params["rx_id"], job.params["planned_at"])
await _system(job.token, "med_reminder", dose_id=dose_id, drug=rx.drug, dose=rx.dose)
elif job.kind == "missed":
if await _mark_missed(job.params["dose_id"]):
await _after_rules(job.token)
elif job.kind == "daily_checkin":
await _system(job.token, "daily_checkin")
elif job.kind == "scale_due":
await _system(job.token, "scale_due", scale=job.params["scale"], scheduled=True)
elif job.kind == "evaluation":
await _after_rules(job.token)
async def _done(job: Job, now: datetime) -> None:
async with get_clinical_session() as s:
s.add(SchedulerEvent(token=job.token, key=job.key, kind=job.kind, due_at=job.due, done_at=now))
async def run_until(now: datetime, cfg: SchedulerConfig | None = None) -> list[Job]:
"""Deliver every due event up to `now` in time order; returns the delivered jobs."""
delivered: list[Job] = []
for job in await collect(now, cfg):
try:
await _run(job, now)
except Exception as exc: # noqa: BLE001 — one patient's failure must not stop the others
log.error("scheduler job failed token=%s kind=%s error=%s", job.token, job.kind, type(exc).__name__)
continue
await _done(job, now)
delivered.append(job)
if delivered:
log.info("scheduler delivered=%s", [(j.token, j.kind) for j in delivered])
return delivered
+180 -7
View File
@@ -1,13 +1,186 @@
"""Scheduler on the (virtual) demo clock — stub until #13. Called from app.main lifespan.""" """Scheduler on the demo clock: questionnaire jobs when that release is on, else the demo scheduler (#13).
One scheduler per database. PostgreSQL's advisory transaction lock serializes
workers; an asyncio lock does the same within a SQLite demonstration process.
Deliveries are at least once: the channel protocol offers no idempotency key.
"""
import asyncio
import logging
from contextlib import suppress
from datetime import datetime
from zoneinfo import ZoneInfo
from sqlalchemy import select, text
from sqlalchemy.exc import SQLAlchemyError
from app.core import clock
from app.core.intake_runtime import due_scales, intake_enabled, load_runtime
from app.db import repo
from app.db.clinical import PatientProfile
from app.db.intake import QuestionnaireTask
from app.db.session import get_clinical_session
from app.dialog.contracts import Event, Reply, get_engine
from app.integrations.base import integrations
from app.scheduler import demo
from app.services import monitoring
from app.services import questionnaire as service
log = logging.getLogger(__name__)
_worker = None
_lock = asyncio.Lock()
async def start() -> None: async def plan():
"""Start periodic ticking (#13).""" now = clock.now()
p = load_runtime()
local = now.astimezone(ZoneInfo(p.timezone))
async with get_clinical_session() as session:
profiles = (await session.scalars(select(PatientProfile))).all()
for profile in profiles:
state = await repo.get_dialog_state(session, profile.token)
if not state or state.data.get("consent") is not True or state.data.get("adult") is not True:
continue
rows = await service.records(session, profile.token)
initial = next((r for r in reversed(rows) if r.kind == "intake" and r.status == "complete"), None)
if initial is None:
continue
last_activity = max([r.day for r in rows if r.kind == "diary" and r.status == "complete"] + [initial.day])
high_risk = profile.enhanced_monitoring or bool(state.data.get("crisis"))
if high_risk and (local.date() - last_activity).days >= p.high_risk_silence_days:
await service.alert(session, profile.token, f"silence:{last_activity}", "high_risk_silence", now)
if (state.data.get("crisis") or {}).get("open") or state.data.get("intake_review_required"):
continue
if local.hour >= p.daily_hour and not any(
r.kind == "diary" and r.day == local.date() and r.status == "complete" for r in rows
):
await service.queue_task(session, profile.token, "diary", f"diary:{local.date()}", now)
last = {}
for row in rows:
if row.status != "complete":
continue
for sid in ("phq9", "pcl5", "rus_audit_s"):
if row.details.get("totals", {}).get(sid, {}).get("complete"):
last[sid] = row.completed_at.astimezone(ZoneInfo(p.timezone)).date()
route = initial.details["decision"]["primary_route"]
prescriptions = await integrations.prescriptions.get_prescriptions(profile.token)
changes = [
v.start_date
for v in prescriptions
if v.start_date and v.start_date <= local.date() and (v.end_date is None or v.end_date >= local.date())
]
if profile.therapy_start and profile.therapy_start <= local.date():
changes.append(profile.therapy_start)
closed = (state.data.get("crisis") or {}).get("closed_at")
crisis_date = datetime.fromisoformat(closed).astimezone(ZoneInfo(p.timezone)).date() if closed else None
scales = due_scales(
today=local.date(),
enrolled=initial.day,
last=last,
route="self_help" if route == "self_help" else "specialist",
medication_changed=max(changes) if changes else None,
after_crisis=crisis_date,
policy=p,
)
if scales or local.hour >= p.daily_hour:
await service.queue_task(session, profile.token, "cssrs_since_contact", f"cssrs:{local.date()}", now)
for sid in scales:
if sid == "pcl5" and initial.answers.get("trauma") != 1:
continue
await service.queue_task(session, profile.token, sid, f"{sid}:{local.date()}", now)
async def stop() -> None: async def deliver():
"""Stop the scheduler (#13).""" async with get_clinical_session() as session:
task_ids = list(
(
await session.scalars(
select(QuestionnaireTask.id)
.where(
QuestionnaireTask.sent_at.is_(None),
QuestionnaireTask.status != "cancelled",
QuestionnaireTask.due_at <= clock.now(),
)
.order_by((QuestionnaireTask.kind != "cssrs_since_contact"), QuestionnaireTask.id)
)
).all()
)
for task_id in task_ids:
async with get_clinical_session() as session:
task = await session.get(QuestionnaireTask, task_id)
state = await repo.get_dialog_state(session, task.token)
if not state or state.data.get("consent") is not True or state.data.get("adult") is not True:
task.status, task.delivery = "cancelled", []
continue
if (state.data.get("crisis") or {}).get("open"):
continue
if task.status == "done" and not task.delivery:
continue
token = task.token
payload = task.delivery
if not payload and state.state not in ("idle", "intake.collect"):
continue
if not payload:
await get_engine().handle(
token, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id})
)
# Serialize delivery with withdrawal of consent and user answers.
async with get_clinical_session() as session:
await service.lock_patient(session, token)
state = await repo.get_dialog_state(session, token)
task = await session.get(QuestionnaireTask, task_id)
if not state or state.data.get("consent") is not True or not task.delivery:
continue
try:
await asyncio.wait_for(
integrations.outbox.push(token, [Reply.model_validate(r) for r in task.delivery]), timeout=10
)
except Exception as exc:
log.warning("questionnaire outbox error=%s", type(exc).__name__)
else:
task.sent_at = clock.now()
async def run_until_now() -> None: async def run_until_now():
"""Replay all due events up to `app.core.clock.now()` in order, idempotently (#13).""" async with _lock, get_clinical_session() as guard:
if guard.bind.dialect.name == "postgresql" and not await guard.scalar(
text("SELECT pg_try_advisory_xact_lock(520053)")
):
return
await service.dispatch_notices()
if not intake_enabled():
# Questionnaire release off → the demo scheduler (doses, check-ins, scales, silence) runs instead.
if demo.load_config().enabled:
await demo.run_until(clock.now())
return
await plan()
await deliver()
await service.dispatch_notices()
async def _run():
while True:
try:
await run_until_now()
except Exception as exc:
log.error("questionnaire scheduler error=%s", type(exc).__name__)
await asyncio.sleep(demo.load_config().tick_seconds)
async def start():
global _worker
try:
await monitoring.init_clock_persistence()
except (OSError, SQLAlchemyError) as exc:
log.error("clock persistence unavailable error=%s", type(exc).__name__)
if _worker is None and (intake_enabled() or demo.load_config().enabled):
_worker = asyncio.create_task(_run())
async def stop():
global _worker
if _worker is not None:
_worker.cancel()
with suppress(asyncio.CancelledError):
await _worker
_worker = None
+157
View File
@@ -0,0 +1,157 @@
"""Monitoring service (#38): patient snapshot → deterministic rules → persisted alerts (ADR-007).
`after_data_change(token)` is called by the dialog engine after it commits clinical data (and by the scheduler
on silence). It builds a `PatientSnapshot`, runs `app.core.rules.evaluate`, then in one transaction:
* writes every `alert`/`crisis` action to `alerts` (a crisis is an `acute` alert) and, for a crisis,
an audit record `crisis_acute` (guardrail #6);
* applies `enhanced_monitoring` to the profile;
* refreshes the cached `status_color` of the profile (ADR-006; the source of truth stays the open alerts).
After the commit the doctor is notified through `integrations.notifier` (a crisis also goes to the doctor on
duty), and only the *dialog* actions are returned to the engine — it never creates alerts itself.
A crisis rule whose acute alert is still open stays silent: the crisis script is already running and the
doctor already knows, so it is neither restarted nor re-alerted (`rules.evaluate` never suppresses crises).
The virtual clock offset is persisted in `clock_state` (ADR-005) by `init_clock_persistence()`.
"""
import asyncio
import logging
from datetime import datetime, timedelta
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import clock
from app.core.rules import Action, evaluate, patient_status
from app.core.types import AlertLevel
from app.db import repo
from app.db.audit import audit
from app.db.session import get_clinical_session
from app.integrations.base import integrations
from app.services.contracts import register_monitoring
from app.services.snapshot import build_snapshot
log = logging.getLogger(__name__)
SYSTEM_ACTOR = "system"
# Notification address of the doctor on duty: acute alerts must reach someone even when the attending doctor
# is off (SPEC: crisis → immediate escalation). The notifier maps it to the duty channel.
ON_DUTY_DOCTOR = "on-duty"
# Persisted by the service itself, never passed to the dialog engine.
_SERVICE_ACTIONS = frozenset({"alert", "enhanced_monitoring"})
async def refresh_status(session: AsyncSession, token: str) -> str:
"""Recompute and cache the dashboard colour from open alerts (also call it after an ack)."""
alerts = await repo.list_alerts(session, token=token, open_only=True)
status = patient_status(AlertLevel[a.level.upper()] for a in alerts)
await repo.upsert_profile(session, token=token, status_color=status.color)
return status.color
def _drop_running_crises(actions: list[Action], open_acute: set[str]) -> list[Action]:
"""Silence every action of a crisis rule whose acute alert is still open (see module docstring)."""
running = {a.code for a in actions if a.type == "crisis" and a.code in open_acute}
return [a for a in actions if a.code not in running]
class RuleMonitoring:
async def after_data_change(self, token: str) -> list[Action]:
now = clock.now()
notify: list[tuple[str, str, str]] = [] # (doctor_id, level, text) — sent after the commit
async with get_clinical_session() as s:
profile = await repo.get_profile(s, token)
snapshot = await build_snapshot(s, token, now, profile=profile)
if profile is None or snapshot is None:
return []
open_acute = {a.code for a in await repo.list_alerts(s, token=token, open_only=True) if a.level == "acute"}
actions = _drop_running_crises(evaluate(snapshot), open_acute)
for action in actions:
if action.type == "enhanced_monitoring":
profile.enhanced_monitoring = True
if not action.creates_alert or action.level is None:
continue
alert = await repo.add_alert(
s,
token=token,
level=action.level,
code=action.code,
reason=action.reason_ru,
ts=now,
params=action.params,
)
level = action.level.name.lower()
notify.append((profile.doctor_id, level, action.reason_ru))
if action.type == "crisis":
await audit(
s,
actor=SYSTEM_ACTOR,
action="crisis_acute",
token=token,
details={"alert_id": alert.id, "code": action.code},
ts=now,
)
notify.append((ON_DUTY_DOCTOR, level, action.reason_ru))
await refresh_status(s, token)
log.info("monitoring token=%s actions=%s", token, [a.code for a in actions])
for doctor_id, level, text in notify:
await _notify(doctor_id, level, text, token)
return [a for a in actions if a.type not in _SERVICE_ACTIONS]
async def is_in_crisis(self, token: str) -> bool:
async with get_clinical_session() as s:
alerts = await repo.list_alerts(s, token=token, open_only=True)
return any(a.level == "acute" for a in alerts)
async def _notify(doctor_id: str, level: str, text: str, token: str) -> None:
# The alert is already committed and visible on the dashboard; a failing channel must not undo it.
try:
await integrations.notifier.send(doctor_id, level, text, token) # type: ignore[arg-type]
except Exception as exc: # noqa: BLE001 — any channel error
log.warning("notify failed token=%s level=%s error=%s", token, level, type(exc).__name__)
register_monitoring(RuleMonitoring)
# ---------------------------------------------------------------------------------- demo clock persistence
_writes: set[asyncio.Task[None]] = set()
async def _write_offset() -> None:
# Writes the offset current at write time, so out-of-order tasks still leave the latest value stored.
try:
async with get_clinical_session() as s:
await repo.save_clock_offset(s, clock.offset().total_seconds())
except (OSError, SQLAlchemyError) as exc:
log.warning("clock offset not saved error=%s", type(exc).__name__)
def _save_offset(_: timedelta) -> None:
"""Sync hook for `clock.register_persistence`: schedules the DB write on the running loop."""
try:
loop = asyncio.get_running_loop()
except RuntimeError:
log.warning("clock offset not saved: no running event loop")
return
task = loop.create_task(_write_offset())
_writes.add(task)
task.add_done_callback(_writes.discard)
async def flush_clock_writes() -> None:
"""Wait for pending offset writes (tests, shutdown)."""
if _writes:
await asyncio.gather(*list(_writes))
async def init_clock_persistence() -> datetime:
"""Restore the virtual clock offset from `clock_state` and save every later change there."""
async with get_clinical_session() as s:
stored = timedelta(seconds=await repo.load_clock_offset(s))
clock.register_persistence(lambda: stored, _save_offset)
log.info("clock offset restored seconds=%s", int(stored.total_seconds()))
return clock.now()
+264
View File
@@ -0,0 +1,264 @@
"""Transactional questionnaire storage and a durable doctor notification queue.
Notifier acceptance is not delivery or a doctor's acknowledgement. At-least-once
retries can duplicate delivery if a process dies after sending but before commit.
"""
import asyncio
from dataclasses import asdict
from datetime import timedelta
from uuid import uuid4
from zoneinfo import ZoneInfo
from sqlalchemy import select
from app.core import clock
from app.core.intake_questions import content, cssrs_answers, group_answers
from app.core.intake_runtime import load_runtime, month_before
from app.core.questionnaire import evaluate_intake, load_policy, score_alcohol, score_cssrs_recent
from app.core.scales import load_scale, score
from app.core.types import AlertLevel
from app.db import repo
from app.db.clinical import Alert, DialogState
from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask
from app.db.session import get_clinical_session
from app.integrations.base import MemoryNotifier, integrations
async def records(session, token):
return list(
(
await session.scalars(
select(QuestionnaireRecord)
.where(QuestionnaireRecord.token == token)
.order_by(QuestionnaireRecord.started_at, QuestionnaireRecord.id)
)
).all()
)
async def create_record(ctx, kind, *, task=None):
profile = await ctx.profile()
runtime = load_runtime()
prior = next(
(r for r in reversed(await records(ctx.session, ctx.token)) if r.kind == "intake" and r.status == "complete"),
None,
)
sex = profile.sex if profile else None
if sex is None and prior:
sex = prior.details.get("sex")
row = QuestionnaireRecord(
id=uuid4().hex,
token=ctx.token,
kind=kind,
version=content()["version"],
policy_version=load_policy().version,
started_at=ctx.now,
day=ctx.now.astimezone(ZoneInfo(runtime.timezone)).date(),
status="active",
answers={},
details={
"runtime_version": runtime.version,
"modules": list(profile.modules or []) if profile else [],
"sex": sex,
"medication_active": bool(
(profile and profile.therapy_start and profile.therapy_start <= ctx.now.date())
or (prior and prior.answers.get("psychotropics") == 1)
),
"suicide_monitoring": bool(profile and profile.enhanced_monitoring),
"weekly_pcl": kind == "pcl5" and bool((ctx.data.get("crisis") or {}).get("closed_at")),
"answer_metadata": {},
"task_id": task.id if task else None,
},
)
ctx.session.add(row)
await ctx.session.flush()
if task:
task.record_id = row.id
task.status = "active"
return row
def save_answer(row, question, value, now):
"""Anchor recall windows to administration, never to the day of a resumed answer."""
day = row.day
starts = {
"past_month": month_before(day),
"past_3_months": month_before(day, 3),
"past_week": day - timedelta(days=7),
"past_day": day - timedelta(days=1),
"последние 2 недели": day - timedelta(days=14),
"последний месяц": month_before(day),
"последняя неделя": day - timedelta(days=7),
"прошлая неделя": day - timedelta(days=7),
"последние 12 месяцев": month_before(day, 12),
}
row.answers = {**row.answers, question.id: value}
if question.id == "sex" and value in ("m", "f"):
row.details = {**row.details, "sex": value}
row.details = {
**row.details,
"answer_metadata": {
**row.details.get("answer_metadata", {}),
question.id: {
"answered_at": now.isoformat(),
"period": question.period,
"period_start": starts[question.period].isoformat() if question.period in starts else None,
"period_end": day.isoformat(),
"source": "patient",
"derived": False,
},
},
}
def decision(row):
a = row.answers
sex = row.details.get("sex") or (a.get("sex") if a.get("sex") in ("m", "f") else None)
return evaluate_intake(
phq9=group_answers(a, "phq9"),
tsq=group_answers(a, "tsq"),
pcl5=group_answers(a, "pcl5"),
alcohol=group_answers(a, "rus_audit"),
cssrs=cssrs_answers(a),
sex=sex,
trauma_exposure=bool(a["trauma"]) if "trauma" in a else None,
traumatic_brain_injury=a.get("tbi") == 1,
psychotropics=a.get("psychotropics") == 1,
)
def summary(row):
a = row.answers
totals = {}
for sid in ("phq9", "tsq", "pcl5"):
result = score(sid, group_answers(a, sid))
totals[sid] = {
"complete": result.complete,
"total": result.total if result.complete else None,
"answered": result.answered,
}
sex = row.details.get("sex") or (a.get("sex") if a.get("sex") in ("m", "f") else None)
for sid in ("rus_audit_s", "rus_audit"):
values = {k: a[k] for k in load_scale(sid).item_ids if k in a}
totals[sid] = asdict(score_alcohol(values, short=sid.endswith("_s"), sex=sex))
totals["cssrs_recent"] = asdict(score_cssrs_recent(cssrs_answers(a)))
return totals
async def finish(ctx, row, status, *, extra=None):
row.status = status
row.completed_at = ctx.now if status == "complete" else None
row.details = {**row.details, **(extra or {})}
if row.details.get("task_id"):
task = await ctx.session.get(QuestionnaireTask, row.details["task_id"])
if task:
task.status = "done"
async def alert(session, token, key, code, now, *, level=AlertLevel.MODERATE, params=None):
existing = await session.scalar(
select(QuestionnaireNotice).where(QuestionnaireNotice.token == token, QuestionnaireNotice.event_key == key)
)
if existing:
return existing
row = await repo.add_alert(session, token=token, level=level, code=code, reason=code, ts=now, params=params)
notice = QuestionnaireNotice(token=token, event_key=key, alert_id=row.id, created_at=now)
session.add(notice)
await session.flush()
return notice
async def cancel_pending(session, token):
for row in await records(session, token):
if row.status in ("active", "paused"):
row.status = "cancelled"
tasks = await session.scalars(
select(QuestionnaireTask).where(
QuestionnaireTask.token == token, QuestionnaireTask.status.in_(("pending", "active"))
)
)
for task in tasks:
task.status = "cancelled"
task.delivery = []
notices = await session.scalars(
select(QuestionnaireNotice).where(QuestionnaireNotice.token == token, QuestionnaireNotice.status == "pending")
)
for notice in notices:
notice.status = "cancelled"
async def dispatch_notices(token=None):
now = clock.now()
async with get_clinical_session() as session:
stmt = (
select(QuestionnaireNotice.id, QuestionnaireNotice.token)
.where(QuestionnaireNotice.status == "pending")
.order_by(QuestionnaireNotice.id)
.limit(100)
)
if token:
stmt = stmt.where(QuestionnaireNotice.token == token)
candidates = (await session.execute(stmt)).all()
for notice_id, patient_token in candidates:
async with get_clinical_session() as session:
# Same lock order as withdrawal: patient first, then notice.
await lock_patient(session, patient_token)
notice = await session.scalar(
select(QuestionnaireNotice).where(QuestionnaireNotice.id == notice_id).with_for_update()
)
if notice.status != "pending":
continue
if notice.attempted_at and now < notice.attempted_at + timedelta(
minutes=min(60, 2 ** min(notice.attempts, 6))
):
continue
state = await repo.get_dialog_state(session, notice.token)
if state is None or state.data.get("consent") is not True:
notice.status = "cancelled"
continue
profile = await repo.get_profile(session, notice.token)
if not profile or not profile.doctor_id:
continue
item = await session.get(Alert, notice.alert_id)
notice.attempted_at, notice.attempts = now, notice.attempts + 1
try:
await asyncio.wait_for(
integrations.notifier.send(profile.doctor_id, item.level, item.code, token=notice.token), timeout=10
)
except Exception as exc:
notice.error_code = type(exc).__name__[:80]
else:
notice.error_code = None
notice.accepted_at = now
notice.status = (
"test_sink" if isinstance(integrations.notifier, MemoryNotifier) else "accepted_by_adapter"
)
async def queue_task(session, token, kind, key, now):
existing = await session.scalar(
select(QuestionnaireTask).where(QuestionnaireTask.token == token, QuestionnaireTask.task_key == key)
)
if existing:
return existing
# Only one outstanding administration per form, however many ticks were missed.
existing = await session.scalar(
select(QuestionnaireTask).where(
QuestionnaireTask.token == token,
QuestionnaireTask.kind == kind,
QuestionnaireTask.status.in_(("pending", "active")),
)
)
if existing:
return existing
row = QuestionnaireTask(token=token, kind=kind, task_key=key, due_at=now)
session.add(row)
await session.flush()
return row
async def lock_patient(session, token):
# PostgreSQL serializes callbacks from different workers. SQLite tests additionally
# use the engine's in-process lock. Consent and answers share this transaction.
await session.scalar(select(DialogState).where(DialogState.token == token).with_for_update())
+93
View File
@@ -0,0 +1,93 @@
"""Patient snapshot for the rules engine, assembled from the clinical DB (ADR-007).
Maps storage names to `app.core.rules` names (`sleep_bucket` → `sleep_hours_bucket`, `alcohol_bucket` →
`alcohol`), derives age from `birth_year` and the therapy day from `therapy_start`. Time comes in as `as_of`
(from `app.core.clock`), never from the wall clock. Must not import `app.db.identity` (guardrail #5).
"""
from datetime import datetime, timedelta
from typing import Any, get_args
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.rules import (
CssrsRisk,
DailyCheckin,
DoseRecord,
Module,
PatientSnapshot,
ScalePoint,
)
from app.db import repo
from app.db.clinical import Dose, Message, PatientProfile
# Risk screens are stored as scale results but reach the rules as `cssrs_risk`, not as scale points.
CSSRS_SCALES = frozenset({"cssrs_screen", "cssrs"})
# A C-SSRS result counts as the current risk for this long (same window as PHQ-9 item 9 and the dialog's
# C-SSRS cooldown): one old "high" must not restart the crisis protocol after the doctor has acted on it.
CSSRS_RECENT = timedelta(hours=24)
# Unknown sex/age fall back to the more cautious option: lower AUDIT-C cut-off, young-adult monitoring.
_UNKNOWN_SEX = "f"
_UNKNOWN_AGE = 0
_MODULES = frozenset(get_args(Module))
_CSSRS_RISKS = frozenset(get_args(CssrsRisk))
def _cssrs_risk(results: list[Any], as_of: datetime) -> CssrsRisk | None:
recent = [r for r in results if r.scale in CSSRS_SCALES and as_of - CSSRS_RECENT < r.ts <= as_of]
if not recent:
return None
severity = recent[-1].severity
return severity if severity in _CSSRS_RISKS else None
async def build_snapshot(
session: AsyncSession, token: str, as_of: datetime, *, profile: PatientProfile | None = None
) -> PatientSnapshot | None:
"""Snapshot of everything the rules may look at; None if the patient has no clinical profile."""
profile = profile or await repo.get_profile(session, token)
if profile is None:
return None
checkins = [
DailyCheckin(
day=c.day,
mood=c.mood,
med_taken=c.med_taken,
sleep_hours_bucket=c.sleep_bucket, # type: ignore[arg-type]
alcohol=c.alcohol_bucket, # type: ignore[arg-type]
side_effects=list(c.side_effects or []),
quit_intent=bool(c.quit_intent),
nightmares=c.nightmares,
)
for c in await repo.list_checkins(session, token)
]
results = [r for r in await repo.list_scale_results(session, token) if r.complete]
doses = (await session.scalars(select(Dose).where(Dose.token == token).order_by(Dose.planned_at))).all()
last_in = await session.scalar(
select(func.max(Message.ts)).where(Message.token == token, Message.direction == "in")
)
state = await repo.get_dialog_state(session, token)
facts = (state.data.get("facts") or {}) if state is not None else {}
return PatientSnapshot(
token=token,
as_of=as_of,
age=as_of.year - profile.birth_year if profile.birth_year else _UNKNOWN_AGE,
sex=profile.sex if profile.sex in ("m", "f") else _UNKNOWN_SEX, # type: ignore[arg-type]
modules={m for m in profile.modules or [] if m in _MODULES}, # type: ignore[misc]
therapy_day=(as_of.date() - profile.therapy_start).days if profile.therapy_start else 0,
checkins=checkins,
scale_results=[
ScalePoint(scale=r.scale, ts=r.ts, total=r.total, item9=r.item9, scheduled=r.scheduled)
for r in results
if r.scale not in CSSRS_SCALES
],
doses=[DoseRecord(planned_at=d.planned_at, status=d.status) for d in doses], # type: ignore[arg-type]
cssrs_risk=_cssrs_risk(results, as_of),
weapon_access=facts.get("weapon_access"),
last_patient_message_at=last_in,
open_alert_codes=await repo.open_alert_codes(session, token),
)
+61
View File
@@ -0,0 +1,61 @@
# Verbatim source content; only whitespace/line wraps normalized. Not enabled in the legacy dialog.
id: cssrs_recent_ru
version: source-2026-10-03
title: КОЛУМБИЙСКАЯ ШКАЛА ОЦЕНКИ ТЯЖЕСТИ СУИЦИДА
subtitle: Screen Version - Recent
period_label_original: Последние 1 месяца
initial_instruction: Задайте вопросы 1 и 2
branch_instruction: ЕСЛИ ДА 2, Задайте вопросы 3, 4, 5 и 6. ЕСЛИ Нет, Задайте вопросы 6.
options:
- value: 1
label: Да
- value: 0
label: Нет
items:
- id: q1
text: Возникало ли у Вас желание умереть или заснуть и не просыпаться?
interviewer_guidance: null
recall_period: past_month
yes_risk: low
- id: q2
text: Думали ли Вы в самом деле о том, чтобы убить себя?
interviewer_guidance: null
recall_period: past_month
yes_risk: low
- id: q3
text: Думали ли Вы о том, как Вы могли бы это сделать?
interviewer_guidance: 'Пациент подтверждает, что его посещают мысли о самоубийстве и что в течение периода наблюдения
он/она думал(-а) хотя бы об одном способе самоубийства. Данная ситуация отлична от такой, когда пациент разработал
конкретный план: время, место или детали метода самоубийства (например, пациент думал о способе самоубийства,
но не о конкретном плане). Сюда относятся люди, которые скажут: “Я думал(-а) о передозировке таблеток, но так
и не построил(-а) конкретного плана о том, когда, где и как осуществить это…..и я бы никогда не довёл(-а) дело
до конца”.'
recall_period: past_month
yes_risk: moderate
- id: q4
text: Посещали ли Вас подобные мысли, и было ли у Вас какое-нибудь намерение действовать в соответствии с ними?
interviewer_guidance: 'Активные мысли о самоубийстве при заявлении пациента о некотором намерении действовать
в соответствии с этими мыслями, в отличие от подобного утверждения: “Такие мысли посещают меня, но я определенно
не стану ничего предпринимать”.'
recall_period: past_month
yes_risk: high
- id: q5
text: Начали ли Вы разрабатывать или уже разработали детальный план самоубийства? Намереваетесь ли Вы осуществить
этот план?
interviewer_guidance: null
recall_period: past_month
yes_risk: high
- id: q6_lifetime
text: Вы когда-нибудь что-нибудь делали, начинали что-то делать или готовились сделать что-нибудь, чтобы покончить
с собой?
interviewer_guidance: 'Примеры: запаслись таблетками, приобрели пистолет, раздали ценные вещи, написали завещание
или предсмертную записку, запаслись таблетками, но не проглотили их, держали пистолет, но передумали стрелять
или его кто-то выхватил из вашей руки, поднялись на крышу, но не прыгнули; или принимали таблетки, пытались
застрелиться, порезались, пытался повеситься и т. д.'
recall_period: lifetime
yes_risk: moderate
- id: q6_recent
text: ЕСЛИ ДА, то было ли это в последние 3 месяца?
interviewer_guidance: null
recall_period: past_3_months
yes_risk: high
+189
View File
@@ -0,0 +1,189 @@
version: diary-v2-2026-10-03
core:
- id: mood
text: 'Какое у Вас сегодня настроение?
0 — самое плохое, 10 — самое хорошее.'
options:
- value: 0
label: '0'
- value: 1
label: '1'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- value: 5
label: '5'
- value: 6
label: '6'
- value: 7
label: '7'
- value: 8
label: '8'
- value: 9
label: '9'
- value: 10
label: '10'
- id: sleep_hours
text: Сколько часов Вы спали прошлой ночью?
numeric: true
- id: rested
text: 'Насколько Вы выспались?
0 — совсем не выспались, 4 — полностью выспались.'
options:
- value: 0
label: '0'
- value: 1
label: '1'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- id: medication
text: Приняли ли Вы сегодня лекарства по схеме?
options:
- value: full
label: По схеме
- value: partial
label: Частично
- value: none
label: Нет
- value: not_prescribed
label: Не назначены
- id: medication_reason
text: Почему не приняли лекарства по схеме?
options:
- value: forgot
label: Забыл(а)
- value: side_effects
label: Побочные эффекты
- value: declined
label: Не хочу
- value: ran_out
label: Закончились
- value: other
label: Другая причина
- id: side_effects
text: Беспокоили ли Вас сегодня побочные эффекты лекарств?
options: &id001
- value: 1
label: Да
- value: 0
label: Нет
- id: substances
text: Употребляли ли Вы сегодня алкоголь или другие вещества?
options: *id001
- id: comment
text: Что хотите передать врачу?
optional: true
ptsd:
- id: intrusions
text: 'Насколько за последние сутки Вас беспокоили: нежелательные воспоминания о событии?'
options: &id002
- value: 0
label: Совсем нет
- value: 1
label: Немного
- value: 2
label: Умеренно
- value: 3
label: Сильно
- value: 4
label: Крайне сильно
- id: nightmares
text: 'Насколько за последние сутки Вас беспокоили: кошмары прошлой ночью?'
options: *id002
- id: avoidance
text: 'Насколько за последние сутки Вас беспокоили: желание избегать мест, людей и мыслей, напоминающих о событии?'
options: *id002
- id: hypervigilance
text: 'Насколько за последние сутки Вас беспокоили: настороженность, вздрагивание, ощущение опасности?'
options: *id002
- id: irritability
text: 'Насколько за последние сутки Вас беспокоили: раздражительность или вспышки гнева?'
options: *id002
depression:
- id: interest
text: 'Насколько Вам сегодня было интересно и приятно что-то делать?
0 — совсем нет, 4 — крайне сильно.'
options: *id002
- id: depressed
text: Насколько Вы сегодня чувствовали подавленность или безнадёжность?
options: *id002
- id: energy
text: 'Насколько у Вас сегодня были силы и энергия?
0 — совсем нет, 4 — крайне сильно.'
options: *id002
- id: planned_activity
text: Сделали ли Вы сегодня хотя бы одно запланированное дело?
options: *id001
suicide:
- id: death_thoughts
text: Были ли у Вас сегодня мысли о смерти или о том, что не хочется жить?
options: *id001
- id: thoughts_intensity
text: 'Если да — насколько сильными они были?
0 — совсем слабые, 10 — максимально сильные.'
options:
- value: 0
label: '0'
- value: 1
label: '1'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- value: 5
label: '5'
- value: 6
label: '6'
- value: 7
label: '7'
- value: 8
label: '8'
- value: 9
label: '9'
- value: 10
label: '10'
- id: hope
text: 'Насколько Вы верите, что станет лучше?
0 — совсем не верю, 10 — полностью верю.'
options:
- value: 0
label: '0'
- value: 1
label: '1'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- value: 5
label: '5'
- value: 6
label: '6'
- value: 7
label: '7'
- value: 8
label: '8'
- value: 9
label: '9'
- value: 10
label: '10'
- id: burden
text: Чувствовали ли Вы себя обузой для близких?
options: *id001
weekly: true
+167
View File
@@ -0,0 +1,167 @@
version: mvp-v2-2026-10-03
forms:
phq9:
id: phq9
title: Опросник здоровья пациента (PHQ-9)
recall_period: последние 2 недели
instruction: Как часто за последние 2 недели Вас беспокоили следующие проблемы?
license_note: PHQ-9 (Pfizer) — свободное использование, разрешения не требуется.
translation_note: Стандартная русская версия PHQ-9 (MAPI). Финальную формулировку утверждает психиатр.
options:
- value: 0
label: Ни разу
- value: 1
label: Несколько дней
- value: 2
label: Более недели
- value: 3
label: Почти каждый день
items:
- id: phq9_1
text: Вам не хотелось ничего делать
- id: phq9_2
text: У Вас было плохое настроение, Вы были подавлены или испытывали чувство безысходности
- id: phq9_3
text: Вам было трудно заснуть, у Вас был прерывистый сон, или Вы слишком много спали
- id: phq9_4
text: Вы были утомлены, или у Вас было мало сил
- id: phq9_5
text: У Вас был плохой аппетит, или Вы переедали
- id: phq9_6
text: 'Вы плохо о себе думали: считали себя неудачником (неудачницей), или были в себе разочарованы, или считали,
что подвели свою семью'
- id: phq9_7
text: Вам было трудно сосредоточиться (например, на чтении газеты или на просмотре телепередач)
- id: phq9_8
text: Вы двигались или говорили настолько медленно, что окружающие это замечали? Или, наоборот, Вы были настолько
суетливы или взбудоражены, что двигались гораздо больше обычного
- id: phq9_9
text: Вас посещали мысли о том, что Вам лучше было бы умереть, или о том, чтобы причинить себе какой-нибудь
вред
buttons_only: true
range:
min: 0
max: 27
severity:
- min: 0
max: 4
code: minimal
label: Минимальная
- min: 5
max: 9
code: mild
label: Лёгкая
- min: 10
max: 14
code: moderate
label: Умеренная
- min: 15
max: 19
code: moderately_severe
label: Умеренно-тяжёлая
- min: 20
max: 27
code: severe
label: Тяжёлая
flags:
- code: phq9_item9_positive
when:
item: phq9_9
op: '>='
value: 1
pcl5:
id: pcl5
title: Контрольный список симптомов ПТСР (PCL-5)
recall_period: последний месяц
instruction: 'Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового
события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант,
который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц.
Насколько сильно за последний месяц Вас беспокоили:'
license_note: PCL-5 — общественное достояние (VA National Center for PTSD); русская версия — лицензия на проверке.
translation_note: Рабочий перевод команды (демо-текст). Финальный русский перевод выбирает психиатр (SPEC §9).
Для еженедельного мониторинга используется та же шкала с периодом «последняя неделя».
options:
- value: 0
label: Совсем нет
- value: 1
label: Немного
- value: 2
label: Умеренно
- value: 3
label: Сильно
- value: 4
label: Крайне сильно
items:
- id: pcl5_1
text: Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?
- id: pcl5_2
text: Повторяющиеся тяжёлые сны о стрессовом событии?
- id: pcl5_3
text: Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно
вернулись туда и переживаете его заново)?
- id: pcl5_4
text: Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?
- id: pcl5_5
text: Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение,
затруднённое дыхание, потливость)?
- id: pcl5_6
text: Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?
- id: pcl5_7
text: Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов
или ситуаций)?
- id: pcl5_8
text: Трудности с тем, чтобы вспомнить важные части стрессового события?
- id: pcl5_9
text: Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то
серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?
- id: pcl5_10
text: Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?
- id: pcl5_11
text: Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?
- id: pcl5_12
text: Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?
- id: pcl5_13
text: Ощущение отдалённости или оторванности от других людей?
- id: pcl5_14
text: Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость
или любовь к близким людям)?
- id: pcl5_15
text: Раздражительность, вспышки гнева или агрессивное поведение?
- id: pcl5_16
text: Чрезмерный риск или поступки, которые могут причинить Вам вред?
- id: pcl5_17
text: Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?
- id: pcl5_18
text: Взвинченность или склонность легко вздрагивать?
- id: pcl5_19
text: Трудности с концентрацией внимания?
- id: pcl5_20
text: Трудности с засыпанием или прерывистый сон?
range:
min: 0
max: 80
severity:
- min: 0
max: 32
code: below_threshold
label: Ниже порога ПТСР
- min: 33
max: 49
code: probable
label: Вероятное ПТСР
- min: 50
max: 80
code: high
label: Выраженная симптоматика ПТСР
flags:
- code: pcl5_probable_ptsd
when:
total: true
op: '>='
value: 33
trauma_introduction: 'Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или
травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение
или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства
или самоубийства.'
trauma_filter: Случалось ли с Вами подобное событие?
+26
View File
@@ -0,0 +1,26 @@
version: intake-v2-2026-10-03
enabled: false
approval: null
# Release decisions cover texts, diary anchors/thresholds, schedules and contact readiness.
content_review: null
contact_review: null
timezone: Europe/Moscow
daily_hour: 19
mood_baseline_days: 7
mood_bad_days: 3
mood_drop: 3
sleep_bad_days: 3
sleep_hours_below: 4
nightmares_at: 3
missed_medication_days: 3
high_risk_silence_days: 2
medication_days: [7, 14, 28]
medication_repeat_days: 28
route_intervals:
after_crisis: {phq9: 7, pcl5: 14}
specialist: {phq9: 14, pcl5: 30}
self_help: {phq9: 28, pcl5: 28}
alcohol_repeat_days: 30
phq9_retriage_delta: 5
pcl5_retriage_delta: 10
+13
View File
@@ -0,0 +1,13 @@
# Review candidate from the supplied MVP PDF, not a signed clinical protocol.
version: mvp-v2-review-2026-10-03
approval: null
cssrs_positive_policy: pending
rus_audit_s_expand_from_zone: null
phq2_expand_at: 2
tsq_expand_at: 6
phq9_psychologist_at: 5
phq9_specialist_at: 10
phq9_priority_at: 20
pcl5_specialist_at: 31
pcl5_priority_at: 50
pcl5_behavior_flag_at: 3
+58
View File
@@ -0,0 +1,58 @@
id: rus_audit
title: RUS-AUDIT
recall_period: "Последние 12 месяцев; п.9 и п.10 также учитывают более ранние события"
license_note: "RUS-AUDIT: © ВОЗ, 2021, CC BY-NC-SA 3.0 IGO; некоммерческое использование. См. docs/clinical/README.md."
translation_note: "Текст из предоставленного PDF «Опросники для MVP», с.7–9. Самозаполнение полной интервью-формы не утверждено."
notes: "Пункт 2 требует таблицы стандартных порций из исходного бланка. Не заменять произвольным калькулятором. Автопереходы не утверждены."
options:
- {value: 0, label: "Никогда"}
- {value: 1, label: "Реже одного раза в месяц"}
- {value: 2, label: "Ежемесячно"}
- {value: 3, label: "Еженедельно"}
- {value: 4, label: "Ежедневно или почти ежедневно"}
items:
- id: rus_audit_1
text: "Как часто Вы употребляете алкогольные напитки?"
options:
- {value: 0, label: "Никогда"}
- {value: 1, label: "Раз в месяц или реже"}
- {value: 2, label: "2–4 раза в месяц"}
- {value: 3, label: "2–3 раза в неделю"}
- {value: 4, label: "4 раза в неделю и чаще"}
- id: rus_audit_2
text: "Сколько алкогольных напитков (стандартных порций) Вы употребляете в типичный день, когда выпиваете? Если в типичный день Вы употребляете несколько разных алкогольных напитков, то суммируйте число СП."
options:
- {value: 0, label: "1–2"}
- {value: 1, label: "3–4"}
- {value: 2, label: "5–6"}
- {value: 3, label: "7–9"}
- {value: 4, label: "10 и больше"}
- {id: rus_audit_3, text: "Как часто Вы употребляете как минимум 1,5 л пива, или как минимум 180 мл крепкого алкоголя, или как минимум бутылку вина или шампанского (750 мл) в течение 24 часов?"}
- {id: rus_audit_4, text: "Как часто за последние 12 месяцев Вы не могли остановиться, начав употреблять алкогольные напитки?"}
- {id: rus_audit_5, text: "Как часто за последние 12 месяцев из-за выпивки Вы не сделали то, что от Вас обычно ожидалось?"}
- {id: rus_audit_6, text: "Как часто за последние 12 месяцев Вам необходимо было выпить утром, чтобы прийти в себя после выпивки (опохмелиться)?"}
- {id: rus_audit_7, text: "Как часто за последние 12 месяцев Вы испытывали чувство вины или сожаления после выпивки?"}
- {id: rus_audit_8, text: "Как часто за последние 12 месяцев Вы были неспособны вспомнить, что было накануне, из-за того, что Вы выпивали?"}
- id: rus_audit_9
text: "Являлось ли Ваше употребление алкогольных напитков причиной травмы у Вас или у других людей?"
options: &history
- {value: 0, label: "Никогда"}
- {value: 2, label: "Да, более 12 месяцев назад"}
- {value: 4, label: "Да, в течение последних 12 месяцев"}
- id: rus_audit_10
text: "Случалось ли, что Ваш близкий человек или родственник, друг или врач беспокоился насчёт употребления Вами алкоголя или советовал выпивать меньше?"
options: *history
range: {min: 0, max: 40}
severity:
- {min: 0, max: 40, code: unclassified, label: "Не выбран пол для интерпретации"}
by_sex:
f:
- {min: 0, max: 4, code: zone_1, label: "Зона I"}
- {min: 5, max: 9, code: zone_2, label: "Зона II"}
- {min: 10, max: 10, code: zone_3, label: "Зона III"}
- {min: 11, max: 40, code: zone_4, label: "Зона IV"}
m:
- {min: 0, max: 8, code: zone_1, label: "Зона I"}
- {min: 9, max: 13, code: zone_2, label: "Зона II"}
- {min: 14, max: 16, code: zone_3, label: "Зона III"}
- {min: 17, max: 40, code: zone_4, label: "Зона IV"}
+41
View File
@@ -0,0 +1,41 @@
id: rus_audit_s
title: RUS-AUDIT-S
recall_period: "Последние 12 месяцев; п.9 и п.10 также учитывают более ранние события"
license_note: "RUS-AUDIT: © ВОЗ, 2021, CC BY-NC-SA 3.0 IGO; некоммерческое использование. См. docs/clinical/README.md."
translation_note: "Транскрипция рис.3 Буновой и соавт., 2022, DOI 10.17116/profmed20222508121; визуально сверена 2026-10-03. Интеграция не утверждена."
notes: "Пункты 3,9,10 полной шкалы. Зоны не задают автоматический переход к полной шкале. Не применять пороги AUDIT-C."
items:
- id: rus_audit_3
text: "Как часто Вы употребляете как минимум 1,5 литра пива, или как минимум 180 мл крепкого алкоголя, или как минимум бутылку вина или шампанского (750 мл) в течение 24 часов?"
options:
- {value: 0, label: "Никогда"}
- {value: 1, label: "Реже одного раза в месяц"}
- {value: 2, label: "Ежемесячно"}
- {value: 3, label: "Еженедельно"}
- {value: 4, label: "Ежедневно или почти ежедневно"}
- id: rus_audit_9
text: |-
Являлось ли Ваше употребление алкогольных напитков причиной травмы* у Вас или у других людей?
*Повреждение организма, вызванное внешним воздействием (ранением, ушибом, ожогом и т.д.).
options: &history
- {value: 0, label: "Никогда"}
- {value: 2, label: "Да, более 12 месяцев назад"}
- {value: 4, label: "Да, в течение последних 12 месяцев"}
- id: rus_audit_10
text: "Случалось ли, что Ваш близкий человек или родственник, друг или врач беспокоился насчет употребления Вами алкоголя или советовал выпивать меньше?"
options: *history
range: {min: 0, max: 12}
severity:
- {min: 0, max: 12, code: unclassified, label: "Не выбран пол для интерпретации"}
by_sex:
f:
- {min: 0, max: 1, code: zone_1, label: "Зона I"}
- {min: 2, max: 4, code: zone_2, label: "Зона II"}
- {min: 5, max: 5, code: zone_3, label: "Зона III"}
- {min: 6, max: 12, code: zone_4, label: "Зона IV"}
m:
- {min: 0, max: 3, code: zone_1, label: "Зона I"}
- {min: 4, max: 6, code: zone_2, label: "Зона II"}
- {min: 7, max: 7, code: zone_3, label: "Зона III"}
- {min: 8, max: 12, code: zone_4, label: "Зона IV"}
+59
View File
@@ -0,0 +1,59 @@
id: tsq
title: Опросник на скрининг ПТСР (TSQ)
recall_period: прошлая неделя
instruction: Эта анкета связана с вашими личными реакциями на травматическое событие, которое случилось с вами.
Ниже указаны некоторые реакции, которые иногда возникают у людей после травматического события. Пожалуйста, ответьте
«Да», если вы испытывали следующие симптомы по крайней мере дважды на прошлой неделе.
translation_note: КР ПТСР 2023, ID 753, приложение Г1, с.129–130 PDF; копия emdr.ru. Опечатки сохранены. Актуальность
редакции не подтверждена.
options:
- value: 1
label: ДА
- value: 0
label: НЕТ
items:
- id: tsq_1
text: Тяжелые мысли или воспоминания о событии приходили мне в голову против моей воли
buttons_only: true
- id: tsq_2
text: Мне снились тяжелые сны о том, что со мной случилось
buttons_only: true
- id: tsq_3
text: Я вдруг замечал(а), что действую и чувствую себя так, как будто бы ситуация повторяется снова
buttons_only: true
- id: tsq_4
text: Когда что-то напоминает мне об этом событии, я чувствую себя подавленным
buttons_only: true
- id: tsq_5
text: Когда что-то напоминало мне о случившемся, я испытывал(а) неприятные физические ощущения (потливость, сбой
дыхания, тошноту, учащение пульса и др.)
buttons_only: true
- id: tsq_6
text: У меня нарушен сон (трудности засыпания или частые пробуждения)
buttons_only: true
- id: tsq_7
text: Я чувствовал(а) постоянное раздражение и гнев
buttons_only: true
- id: tsq_8
text: Мне было сложно сосредоточится
buttons_only: true
- id: tsq_9
text: Я стал более осведомлён о потенциальных опасностях для себя и других
buttons_only: true
- id: tsq_10
text: Я все время был(а) напряжен(а) и вздрагивал(а), если что-то внезапно пугало меня
buttons_only: true
range:
min: 0
max: 10
severity:
- min: 0
max: 5
code: negative
label: Ниже порога
- min: 6
max: 10
code: positive
label: Требуется уточнение
license_note: "Текст приложения Г1 клинических рекомендаций 2023 года; условия использования и актуальную редакцию подтвердить до включения."
+25
View File
@@ -0,0 +1,25 @@
# Demo scheduler on the virtual clock (#13, SPEC §8, ADR-005). Local times are in settings.tz.
# Read by app/scheduler/demo.py; used only while the questionnaire release (intake_runtime.yaml) is off.
#
# Every event is delivered once (idempotency key in `scheduler_events`) and only while it is fresh: an event
# older than its late window is not sent at all — after a long jump of the demo clock the patient does not
# receive a pile of yesterday's reminders, and an undelivered reminder never turns into a "missed" dose.
enabled: true
tick_seconds: 30
doses:
late_hours: 2 # reminder not delivered if the dose time is older than this
missed_after_hours: 2 # reminded dose without an answer → status "missed" (SPEC §8)
daily_checkin:
time: "20:00"
late_hours: 4
scales:
time: "10:00" # scales due today (config/modules.yaml schedules) are offered from this hour
late_hours: 10
evaluation:
time: "09:00" # daily monitoring pass: silence > N days, missed doses → caring contact
late_hours: 15
+8 -8
View File
@@ -14,9 +14,9 @@ resources:
phone: "112" phone: "112"
note: "если есть угроза жизни прямо сейчас" note: "если есть угроза жизни прямо сейчас"
helpline: helpline:
name: "Телефон доверия" name: "Психологическая помощь МЧС России"
phone: "8-800-2000-122" phone: "+7 (495) 989-50-50"
note: "бесплатно, круглосуточно" note: "круглосуточно; стоимость звонка по тарифу оператора"
police: police:
name: "Полиция" name: "Полиция"
phone: "102" phone: "102"
@@ -30,7 +30,7 @@ variants:
себе вред (оружие, таблетки), по возможности уберите это подальше или отдайте близкому человеку себе вред (оружие, таблетки), по возможности уберите это подальше или отдайте близкому человеку
и побудьте рядом с кем-то, кому доверяете. и побудьте рядом с кем-то, кому доверяете.
resources: [emergency, helpline] resources: [emergency, helpline]
doctor_notified: "Ваш врач уже уведомлён и свяжется с вами." doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
grounding: "Сейчас попробуйте сделать медленный вдох на 4 счёта и выдох на 6 — несколько раз. Я рядом." grounding: "Сейчас попробуйте сделать медленный вдох на 4 счёта и выдох на 6 — несколько раз. Я рядом."
patient.harm_others: patient.harm_others:
@@ -39,7 +39,7 @@ variants:
Пожалуйста, отойдите от человека и от всего, чем можно причинить вред, и побудьте там, где спокойнее. Пожалуйста, отойдите от человека и от всего, чем можно причинить вред, и побудьте там, где спокойнее.
Если кому-то угрожает опасность прямо сейчас — позвоните 112. Если кому-то угрожает опасность прямо сейчас — позвоните 112.
resources: [emergency, helpline] resources: [emergency, helpline]
doctor_notified: "Ваш врач уже уведомлён и свяжется с вами." doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
grounding: "Поставьте ступни на пол, сделайте медленный вдох на 4 счёта и выдох на 6 — несколько раз." grounding: "Поставьте ступни на пол, сделайте медленный вдох на 4 счёта и выдох на 6 — несколько раз."
patient.domestic_violence: patient.domestic_violence:
@@ -48,7 +48,7 @@ variants:
Если вам или детям угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112. Если вам или детям угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112.
Кризисные службы и телефон доверия помогут найти безопасное место и поддержку. Кризисные службы и телефон доверия помогут найти безопасное место и поддержку.
resources: [emergency, police, helpline] resources: [emergency, police, helpline]
doctor_notified: "Ваш врач уже уведомлён." doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
grounding: "Если вы сейчас в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6. Я рядом." grounding: "Если вы сейчас в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6. Я рядом."
family.default: family.default:
@@ -58,7 +58,7 @@ variants:
спокойно уберите из доступа оружие, таблетки и острые предметы. Не спорьте и не стыдите — спокойно уберите из доступа оружие, таблетки и острые предметы. Не спорьте и не стыдите —
просто будьте рядом и слушайте. просто будьте рядом и слушайте.
resources: [emergency, helpline] resources: [emergency, helpline]
doctor_notified: "Лечащий врач уже уведомлён." doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
grounding: "Позаботьтесь и о себе: сделайте медленный вдох на 4 счёта и выдох на 6." grounding: "Позаботьтесь и о себе: сделайте медленный вдох на 4 счёта и выдох на 6."
family.domestic_violence: family.domestic_violence:
@@ -68,5 +68,5 @@ variants:
Если угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112. Если угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112.
Кризисные службы помогут с временным убежищем и поддержкой. Кризисные службы помогут с временным убежищем и поддержкой.
resources: [emergency, police, helpline] resources: [emergency, police, helpline]
doctor_notified: "Лечащий врач уже уведомлён." doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
grounding: "Когда будете в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6." grounding: "Когда будете в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6."
+117
View File
@@ -0,0 +1,117 @@
# Тексты диалогового движка (issue #10): согласие, возраст, меню, кризис, шкалы, отзыв согласия.
# СТАТУС: черновик, утверждает психиатр (#8).
#
# Правила текстов: без диагнозов и названий расстройств; без советов по дозам; не обещать конфиденциальность
# сверх реальной; не использовать слова «учёт», «контроль», «психиатрический надзор» (проверяется тестом).
# Кризисный скрипт — в crisis.yaml (отправляется дословно, LLM не вызывается).
status: "черновик, утверждает психиатр (#8)"
settings:
# Повторный C-SSRS по сигналу POSSIBLE не чаще, чем раз в N часов (сигнал всё равно пишется в safety_events).
cssrs_cooldown_hours: 24
# Сколько последних safety-событий хранить в dialog_state.data["safety_events"].
max_safety_events: 50
# Флаги шкал (config/scales/*.yaml), при которых сессия шкалы сразу уходит в кризисный путь.
crisis_flags: [phq9_item9_positive, cssrs_high]
# Шкала-скрининг суицидального риска (POSSIBLE и положительный п.9 PHQ-9 → эта шкала).
risk_screen_scale: cssrs_screen
# Брошенный шаг (чек-ин, шкала): если пациент молчит дольше N часов, следующее событие сначала возвращает
# диалог в idle (уже данные ответы сохранены), затем обрабатывается. 0 — выключено. На кризис не действует.
abandon_after_hours: 6
commands:
# Текстовые команды отзыва согласия (сравниваются после приведения к нижнему регистру).
revoke: ["/revoke", "отозвать согласие", "отзываю согласие"]
consent:
version: "v1-draft-2026-10-03"
text: |-
Здравствуйте! Я — цифровой помощник, который сопровождает вас между визитами к врачу: напоминаю о приёме препарата, спрашиваю о самочувствии и передаю важное вашему лечащему врачу.
Прежде чем начать, мне нужно ваше согласие на обработку персональных данных, в том числе сведений о здоровье (152-ФЗ «О персональных данных», ст. 9 и 10).
• Что обрабатывается: ваши ответы о самочувствии, сне, приёме препарата и результаты коротких опросников. Перед автоматической обработкой из сообщений удаляются имена, телефоны и другие личные данные.
• Кто видит данные: только ваш лечащий врач. Данные хранятся в России и не передаются третьим лицам без вашего согласия, кроме случаев, прямо предусмотренных законом.
• Я не ставлю диагнозов и не заменяю врача. Решения о лечении принимает только врач.
• Участие добровольное. Отказ никак не влияет на вашу медицинскую помощь.
• Отозвать согласие можно в любой момент в один шаг: кнопкой «Отозвать согласие» или командой /revoke.
Я не служба экстренной помощи. Если вам угрожает опасность прямо сейчас — звоните 112.
accept: "Согласен(на)"
decline: "Не согласен(на)"
refused: |-
Понимаю, это ваше право. Без согласия я не обрабатываю и не сохраняю ваши сообщения. Ваше лечение у врача продолжается как обычно.
Если передумаете — нажмите кнопку ниже или отправьте /start.
reconsider: "Я передумал(а)"
code_invalid: "Код подключения не найден или уже использован. Пожалуйста, уточните код у вашего врача и отправьте его ещё раз."
age_gate:
question: "Уточните, пожалуйста: вам уже исполнилось 18 лет? Можно нажать кнопку или написать год рождения (например, 1990)."
adult: "Да, мне 18 или больше"
minor: "Нет, мне меньше 18"
unclear: "Не удалось понять ответ. Нажмите кнопку или напишите год рождения четырьмя цифрами, например 1990."
minor_stop: |-
Спасибо, что ответили. Этот помощник рассчитан на взрослых, поэтому дальше я не смогу с вами работать и не сохраняю ваши ответы.
Если вам тяжело или нужна поддержка — позвоните на Детский телефон доверия 8-800-2000-122 (бесплатно, анонимно, круглосуточно) или расскажите взрослому, которому доверяете: родителям, учителю, школьному психологу.
Если есть угроза жизни прямо сейчас — звоните 112.
idle:
welcome: |-
Спасибо, всё готово! Я буду на связи каждый день: напомню о препарате и спрошу о самочувствии. Если захотите что-то рассказать — просто напишите или выберите кнопку ниже.
menu_prompt: "Чем могу помочь?"
buttons:
checkin: "Как я сегодня"
med_question: "Вопрос о лекарстве"
contact_doctor: "Связаться с врачом"
revoke: "Отозвать согласие"
# Заглушки кнопок меню; ветки ветерана (#36/#37) и med_qa (#15) подменяют их своими триггерами.
stub_checkin: "Короткий опрос о самочувствии скоро появится здесь. А пока можно просто написать, как вы себя чувствуете сегодня."
stub_med_question: "Напишите ваш вопрос о препарате. Я отвечаю только по официальной инструкции, а если ответа в ней нет — передам вопрос врачу."
contact_doctor_ack: "Я отметил вашу просьбу связаться с врачом — она появится в вашей карточке у врача. Если ситуация срочная, звоните 112."
visit_stub: "Онлайн-запись появится здесь позже. Пока вы можете записаться к своему врачу через регистратуру клиники."
revoked:
confirm: |-
Согласие отозвано. Я больше не буду писать вам и обрабатывать ваши сообщения, а ваши данные помечены к удалению.
Если передумаете — отправьте /start. Если вам угрожает опасность — звоните 112.
crisis:
# Повторный острый сигнал в уже открытом кризисе: скрипт второй раз не шлём, только это + ресурсы.
repeat: "Я рядом. Ваш при угрозе жизни звоните 112. Если вам угрожает опасность прямо сейчас — позвоните:"
# Любое другое сообщение, пока кризис открыт (до подтверждения врача).
hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия +7 (495) 989-50-50."
# Врач подтвердил кризисный алерт (doctor_ack): выходим из кризиса.
closed: "Ваш врач получил сообщение и в курсе вашей ситуации. Спасибо, что остаётесь на связи. Если снова станет тяжело — напишите мне в любой момент."
scale:
intro: "Несколько коротких вопросов ({count}). {instruction}"
question: "{n}/{total}. {text}"
clarify: "Не совсем понял ответ. Выберите, пожалуйста, вариант кнопкой или опишите, как часто это было."
buttons_only: "На этот вопрос, пожалуйста, ответьте кнопкой."
done: "Спасибо, ответы сохранены. Ваш врач увидит результаты."
risk_screen_intro: "Спасибо, что поделились. Мне важно задать несколько коротких вопросов, чтобы лучше понять, как вы сейчас."
safety_plan:
title: "Ваш план безопасности:"
# Шаблон Стэнли–Брауна, если план ещё не составлен с врачом на визите.
template:
- "Мои тревожные сигналы: что я замечаю, когда становится хуже."
- "Что помогает мне самому: прогулка, душ, дыхание, любимое дело."
- "Люди и места, которые отвлекают: к кому можно пойти."
- "Кому позвонить, если тяжело: близкий человек, сослуживец."
- "Специалисты: ваш врач, телефон доверия +7 (495) 989-50-50, экстренные службы 112."
- "Безопасная среда: оружие и лишние лекарства — на хранение близкому человеку."
means_restriction: "Если дома есть оружие или запас лекарств — попросите близкого человека временно хранить их у себя."
reach_out:
silence: "Давно не было вестей от вас. Как вы? Можно ответить одним словом — мне важно знать, что с вами всё в порядке."
missed_doses: "Заметил, что несколько приёмов препарата пропущено. Всё ли в порядке? Если что-то мешает — расскажите, я передам врачу."
default: "Хотел узнать, как вы. Напишите пару слов, когда будет удобно."
visit:
button: "Записаться к врачу раньше"
unavailable: |-
Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже.
Если вам плохо прямо сейчас — звоните 112 или на телефон доверия +7 (495) 989-50-50.
+33
View File
@@ -0,0 +1,33 @@
unavailable: "Этот опрос пока недоступен. Если нужна помощь, свяжитесь с врачом."
done: "Ответы сохранены. Дальнейшие действия можно обсудить с врачом."
review: "Опрос приостановлен. Для продолжения нужна оценка специалиста. Если есть непосредственная угроза жизни — позвоните 112."
paused: "Опрос приостановлен, ответы сохранены."
invalid: "Выберите один из предложенных вариантов ответа."
numeric: "Введите число часов от 0 до 24, например 6,5."
help: "Мне нужна помощь сейчас"
pause: "Продолжить позже"
resume: "Продолжить опрос"
start: "Пройти первичный опрос"
comment_skip: "Не добавлять комментарий"
recent_trauma: "Событие произошло менее месяца назад?"
sex: "Укажите пол для интерпретации алкогольной шкалы."
tbi: "Были ли у Вас контузия или черепно-мозговая травма?"
psychotropics: "Принимаете ли Вы сейчас психотропные препараты?"
missing_followup: "Для оценки изменений со времени прошлого контакта нужна консультация специалиста. Если Вам нужна помощь сейчас, нажмите кнопку ниже."
periods:
past_month: "За последний месяц."
lifetime: "За всю жизнь."
past_3_months: "За последние три месяца."
standard_drinks: |-
Стандартная порция (СП) = 10 г этанола. Таблица бланка RUS-AUDIT:
Вино/шампанское · пиво · креплёное вино · крепкий алкоголь
1–2 СП: до 250 мл · до 650 мл · до 170 мл · до 80 мл
3–4 СП: 251–450 мл · 651–1200 мл · 171–300 мл · 81–140 мл
5–6 СП: 451–660 мл · 1201–1750 мл · 301–430 мл · 141–210 мл
7–9 СП: 661–970 мл · 1751–2500 мл · 431–640 мл · 211–300 мл
10+ СП: более 970 мл · более 2500 мл · более 640 мл · более 300 мл
Если напитков несколько, учитывайте суммарное число стандартных порций.
as_of: "Период отсчитывается от даты начала этого опроса: {day}."
alcohol_advice: "Обсудите с врачом, как сократить употребление алкоголя и составить безопасный план."
+98
View File
@@ -0,0 +1,98 @@
# Тексты приёма препарата (issue #37): напоминание, пропуск, побочные эффекты, вопрос о лекарстве, ответы
# на действия правил med_info (отсроченный эффект, алкоголь, побочные эффекты).
# СТАТУС: черновик, утверждает психиатр (#8).
#
# Правила текстов: без диагнозов и названий расстройств; без советов по дозам и схеме приёма (решает врач);
# не использовать слова, которые звучат как постановка на учёт или слежка (проверяется тестом). Ответы о
# препарате берутся только из выдержки инструкции ГРЛС (app/llm/med_qa.py); тексты ниже — обрамление и
# запасные варианты, если выдержки нет.
status: "черновик, утверждает психиатр (#8)"
reminder:
# {drug}, {dose} — из назначения врача (МНН и дозировка как в назначении, без изменений).
text: "Время принять {drug} {dose}. Отметьте, пожалуйста, как получилось."
text_no_drug: "Время принять препарат, назначенный врачом. Отметьте, пожалуйста, как получилось."
taken: "Принял(а)"
skip: "Пропущу"
side_effects: "Есть побочные эффекты"
taken_ack: "Отлично, отметил приём. Спасибо!"
# Кнопка от напоминания, которое уже неактуально (например, вчерашнего).
stale: "Это напоминание уже неактуально. Если хотите отметить самочувствие — нажмите «Как я сегодня»."
skip:
question: "Понял, отметил. Подскажите, что помешало? Это поможет вашему врачу."
reasons:
forgot: "Забыл(а)"
side_effects: "Из-за побочных эффектов"
quit: "Решил(а) бросить"
other: "Другое"
ack:
forgot: "Понял, бывает. Завтра напомню в обычное время."
quit: "Спасибо, что честно сказали — это важно, и решать это лучше вместе с врачом."
other: "Понял, отметил. Если захотите рассказать подробнее — просто напишите."
side_effects:
question: "Что беспокоит? Выберите вариант или «Другое», чтобы описать своими словами."
options:
nausea: "Тошнота"
insomnia: "Бессонница"
headache: "Головная боль"
dizziness: "Головокружение"
agitation: "Беспокойство"
other: "Другое"
# Короткий вопрос к инструкции препарата (med_qa, раздел «побочные эффекты») для выбранного эффекта.
questions:
nausea: "тошнота после приёма, когда пройдёт"
insomnia: "бессонница, нарушения сна"
headache: "головная боль"
dizziness: "головокружение"
agitation: "беспокойство, тревога"
describe: "Опишите, пожалуйста, коротко, что вы заметили."
ack: "Спасибо, отметил. Ваш врач увидит это в вашей карточке."
# Тот же эффект был и вчера: инструкцию второй раз не повторяем.
repeat: "Понимаю, это неприятно. Я отмечаю это каждый день, и ваш врач увидит, если не проходит."
ask_dose: "А препарат сегодня приняли?"
dose_yes: "Да, принял(а)"
dose_no: "Нет"
# Ежедневный чек-ин: если сегодня ещё нет отметки о приёме.
checkin:
question: "Сегодня приняли {drug}?"
question_no_drug: "Сегодня приняли назначенный препарат?"
"yes": "Да"
"no": "Нет"
later: "Ещё нет"
question:
prompt: "Напишите ваш вопрос о препарате. Я отвечаю только по официальной инструкции, а если ответа в ней нет — подскажу обратиться к врачу."
no_prescription: "Я не нашёл в карточке назначенного препарата. Пожалуйста, уточните вопрос у вашего врача."
# Ответы на действие правила med_info. Ответ = lead + выдержка из инструкции (med_qa по разделу section,
# вопрос question) + notice. Если выдержки нет — lead + fallback + notice.
med_info:
delayed_effect:
section: onset
question: "когда начнёт действовать препарат, нет эффекта, только хуже"
lead: "Понимаю, что сейчас тяжело."
fallback: "Эффект таких препаратов обычно наступает не сразу, а через 2–4 недели регулярного приёма. Пожалуйста, не прекращайте приём самостоятельно."
notice: "Я сообщил вашему врачу — он свяжется с вами, чтобы обсудить, как вы себя чувствуете."
alcohol_interaction:
section: alcohol
question: "можно ли алкоголь вместе с препаратом, сон"
lead: "Спасибо, что честно рассказали."
fallback: "В инструкции к препарату есть предупреждение о совместном приёме с алкоголем, а алкоголь сам по себе ухудшает сон."
notice: "Я передал это вашему врачу."
nausea:
section: side_effects
question: "тошнота после приёма, когда пройдёт"
lead: ""
fallback: "Тошнота часто встречается в первые дни приёма и обычно проходит сама."
notice: "Если станет хуже — напишите мне, я передам врачу."
side_effects:
section: side_effects
question: "побочные эффекты в начале приёма"
lead: ""
fallback: "Спасибо, что сообщили о самочувствии. Я отмечу это для вашего врача."
notice: "Если станет хуже — напишите мне, я передам врачу."
+62
View File
@@ -0,0 +1,62 @@
# Тексты ежедневного чек-ина и плановых шкал (issue #37, ADR-002, CLINICAL_MODULES §1–2).
# Ядро (настроение + приём) задаётся всем; вопрос про сон и кошмары — модулю veteran; алкоголь — см. settings.
# СТАТУС: черновик, утверждает психиатр (#8).
#
# Правила текстов: без диагнозов и названий расстройств; без советов по дозам; пациент не видит баллов шкал;
# не использовать слова, которые звучат как постановка на учёт или слежка (проверяется тестом).
status: "черновик, утверждает психиатр (#8)"
settings:
# Модули, которым задаётся вопрос про сон и кошмары (modules.yaml: daily_question = sleep_nightmares).
sleep_modules: [veteran]
# Ежедневный вопрос об алкоголе: модули, где он задаётся всегда (у ветеранов алкоголь — частый способ
# «заглушить» бессонницу и фактор риска, CLINICAL_MODULES §1); остальным — при положительном AUDIT-C
# (порог берётся из правила auditc_positive в config/rules.yaml, ADR-002).
alcohol_daily_modules: [veteran]
# Плановые шкалы, перед которыми показывается дружелюбное вступление (см. scale_intros).
intro_scales: [pcl5, phq9, dar5, auditc]
checkin:
start: "Добрый день! Короткая отметка о самочувствии — это займёт меньше минуты."
mood:
question: "Как ваше настроение сегодня по шкале от 0 до 10? 0 — очень плохо, 10 — отлично."
sleep:
question: "Сколько часов удалось поспать прошлой ночью?"
options:
"<4": "Меньше 4 ч"
"4-6": "4–6 ч"
"6-8": "6–8 ч"
">8": "Больше 8 ч"
nightmares:
question: "Снились ли кошмары или тяжёлые сны?"
"yes": "Да"
"no": "Нет"
alcohol:
question: "Выпивали ли вы алкоголь за последние сутки?"
clarify: "Вы упомянули алкоголь. Сколько примерно выпили за последние сутки?"
options:
none: "Не пил(а)"
"1-2": "1–2 порции"
more: "Больше"
text:
question: "Как вы? Можно пару слов — что было за день, что беспокоит."
skip: "Пропустить"
buttons_hint: "Выберите, пожалуйста, вариант кнопкой."
done: "Спасибо, записал. Если что-то изменится — пишите в любое время."
already_done: "Сегодня вы уже отмечались, спасибо! Если хотите что-то добавить — просто напишите."
visit:
# Нажата кнопка «Записаться к врачу раньше» (действие правила suggest_visit). Онлайн-записи пока нет —
# просьба передаётся врачу как факт в карточке.
requested: "Передал врачу просьбу о более раннем визите. С вами свяжутся, чтобы договориться о времени. Если станет хуже до визита — пишите мне или звоните 112."
# Дружелюбное вступление перед шкалой (параметр intro у start_scale). Пациент не видит баллов.
scale_intros:
scheduled:
pcl5: "Раз в две недели я прошу ответить на вопросы о том, как беспокоят тяжёлые воспоминания и их последствия. Это займёт 3–4 минуты. Так ваш врач увидит, что меняется со временем."
phq9: "Пора для короткого опроса о настроении и силах за последние две недели — 9 вопросов. Отвечайте так, как есть, правильных ответов нет."
dar5: "Несколько вопросов о раздражительности и вспышках гнева. После службы это бывает у многих, и врачу важно видеть, как это меняется."
auditc: "Три коротких вопроса об алкоголе. Отвечайте как есть: это нужно врачу, чтобы лечение было безопасным."
unscheduled:
phq9: "Последние дни настроение ниже обычного. Давайте я задам несколько вопросов — так ваш врач лучше поймёт, как вы сейчас."
+92
View File
@@ -0,0 +1,92 @@
# Тексты стартового онбординга ветерана (issue #36): вступление, вопрос о травматическом опыте, пол для AUDIT-C,
# вопрос о доступе к оружию, блок безопасного хранения, итог, FAQ.
# СТАТУС: черновик, утверждает психиатр (#8). Ответы FAQ — формулировку проверяет юрист.
#
# Правила (проверяются тестом tests/test_dialog_veteran_onboarding.py):
# * тон — как у сослуживца: на «вы», коротко, без жалости и без нравоучений;
# * без слов «диагноз», «расстройство», «психолог», «психиатр», «болезнь», «псих»;
# * о сервисе — никогда «учёт», «контроль», «надзор» (страх пациента можно процитировать только в подписи
# кнопки FAQ — так человек узнаёт свой вопрос);
# * пациент не видит баллов и названий шкал.
status: "черновик, утверждает психиатр (#8)"
settings:
# Флаги шкал (config/scales/*.yaml), которые считаются сигналом риска для блока безопасного хранения оружия.
# Положительный п.9 PHQ-9 и C-SSRS — суицидальный риск; AUDIT-C — алкоголь как фактор риска при доступе к оружию.
weapon_risk_flags: [phq9_item9_positive, cssrs_high, cssrs_moderate, auditc_positive]
intro:
text: |-
Рад, что вы здесь. Пара слов, прежде чем начнём.
После возвращения многие замечают одно и то же: сон сбит, в голову лезет прошлое, тело всё ещё «на дежурстве», раздражает то, что раньше не трогало, тянет выпить, чтобы отпустило. Это частая реакция на то, через что вы прошли, а не слабость — и с этим реально работают.
Сейчас будет несколько коротких вопросов про сон, напряжение, настроение и алкоголь — минут пять, почти всё кнопками. Ответы увидит только ваш лечащий врач: так он сможет помогать точнее.
go: "Начнём"
repeat: "Когда будете готовы — нажмите «Начнём». Если есть вопросы, ответы на частые — кнопками ниже."
resume: "Давайте продолжим с того места, где остановились. Осталось немного."
buttons_hint: "Пожалуйста, выберите вариант кнопкой ниже."
exposure:
question: |-
Бывало ли с вами событие, которое сильно напугало, потрясло или было по-настоящему опасным для жизни — на службе, в бою или в другое время?
Подробности рассказывать не нужно.
answer_yes: "Да"
answer_no: "Нет"
decline: "Не хочу отвечать"
ack_yes: "Понял. Несколько вопросов о том, как это даёт о себе знать сейчас."
ack_other: "Хорошо, идём дальше."
sex:
question: "Один уточняющий вопрос: ваш пол? Он нужен только для того, чтобы правильно учесть ответы про алкоголь — нормы для мужчин и женщин разные."
male: "Мужской"
female: "Женский"
decline: "Не хочу отвечать"
weapon:
question: |-
Последний вопрос, он про безопасность, а не про проверку: есть ли у вас сейчас доступ к оружию — табельному, охотничьему, наградному или любому другому?
no_access: "Нет"
self: "Есть, хранится у меня"
others_or_safe: "Есть, у других / в сейфе"
decline: "Не хочу отвечать"
ack: "Спасибо, что ответили."
# Блок безопасного хранения — фиксированный текст, показывается только при доступе к оружию И сигнале риска.
safety_block_self: |-
Одна просьба — без нравоучений. Пока вам тяжело, пусть оружие и патроны побудут у того, кому вы доверяете: у брата, друга, в сейфе, ключ от которого у другого человека. Дело не в недоверии к вам. В трудную минуту важно, чтобы между мыслью и действием было время — это одна из самых надёжных мер защиты, и так делают многие.
Это можно спокойно обсудить с врачом на приёме.
safety_block_stored: |-
Хорошо, что оружие хранится не под рукой. Пусть пока так и остаётся: ключ или код — у человека, которому вы доверяете, а патроны — отдельно. В трудную минуту важно, чтобы между мыслью и действием было время.
Это можно спокойно обсудить с врачом на приёме.
safety_block_unknown: |-
Понимаю, что не хочется об этом говорить. Просто на всякий случай: если дома есть оружие или большой запас лекарств, пусть пока они побудут у человека, которому вы доверяете. В трудную минуту важно, чтобы между мыслью и действием было время.
result:
intro: "Спасибо, что ответили на всё. Коротко, что видно по ответам:"
calm: "Сейчас серьёзных трудностей по ответам не видно. Хорошо — будем держать руку на пульсе."
load: "Нагрузка сейчас заметная. Так бывает у многих после возвращения, и это то, с чем врач может помочь."
ptsd: "• Прошлое пока не отпускает: напряжение, воспоминания, сон."
mood: "• Настроение и силы ниже, чем хотелось бы."
alcohol: "• Алкоголь — стоит обсудить с врачом, особенно если назначат препараты: они с алкоголем плохо сочетаются."
next: "Ваши ответы уже у лечащего врача — он обсудит их с вами на приёме. А я буду на связи каждый день с одним коротким вопросом и напоминанием о препарате."
faq:
who:
button: "Кто увидит мои ответы?"
# формулировку проверяет юрист
text: |-
Только ваш лечащий врач — тот, кто дал вам код подключения. Перед автоматической обработкой из сообщений удаляются имена, телефоны и другие личные данные. Данные хранятся в России.
Отозвать согласие и остановить обработку можно в любой момент — кнопкой «Отозвать согласие» или командой /revoke.
unit:
button: "Это попадёт на учёт / в часть?"
# формулировку проверяет юрист
text: |-
Нет. Это добровольная консультативная помощь между визитами к врачу. Ваши ответы не передаются в часть, военкомат, работодателю или кому-то ещё без вашего согласия — кроме случаев, прямо предусмотренных законом. Отказ от помощника никак не влияет на ваше лечение.
licences:
button: "Заберут права на оружие или вождение?"
# формулировку проверяет юрист
text: |-
Помощник не выдаёт заключений и не отправляет ваши ответы ни в ГИБДД, ни в Росгвардию, ни на медкомиссии. Допуск к оружию и вождению решает отдельная медицинская комиссия по своим правилам, установленным законом.
Как именно это связано с вашим лечением — спросите лечащего врача прямо. Это нормальный вопрос, его задают многие, и честный ответ лучше, чем откладывать помощь из-за неизвестности.
+117
View File
@@ -0,0 +1,117 @@
# Реализация обновлённого опросника — PR #53
Спецификация: 19-страничный PDF, SHA-256
`b5e1cfd2b292526a8cec3bbb679294a90a446c4104a3615c0d8fcbf10e12fc0e`.
Состояние: техническая реализация для проверки; клиническое включение закрыто.
## Состав
- Первичный диалог: официальный русский C-SSRS Recent с условными переходами,
PHQ-2 → PHQ-9 при сумме ≥2, фильтр травмы → TSQ → PCL-5 при TSQ ≥6,
RUS-AUDIT-S 3/9/10 → полная форма по отдельно утверждаемому условию.
Вопросы о недавности травмы, ЧМТ и психотропных препаратах сохраняются отдельно.
- TSQ: дословная инструкция и десять пунктов приложения Г1, с.129–130 PDF
клинических рекомендаций 2023 года. Копия, источник и хеш —
`tsq-guideline-2023.pdf`, `tsq-source.json`. Актуальность редакции не подтверждена.
- Полная алкогольная форма сохраняет ответы 3/9/10 без повторного суммирования;
п.1=0 выводит только п.2=0. Конфликт с положительным п.3 передаётся на проверку.
Отказ указать пол не подменяется выбором мужских или женских границ.
- PHQ/PCL взяты из новой версии PDF; обратный перевод и таблица согласования
находятся в `revision-2026-10-03-v2/`. Тексты не считаются согласованными.
- Дневник: настроение, часы сна, восстановление после сна, приём лекарств и причина
пропуска, переносимость, вещества, необязательный комментарий; модули ПТСР,
депрессии, суицидальных мыслей. У группы с риском вопрос о мыслях задаётся первым.
Общая сумма дневника не вычисляется.
- Повторы: 7/14/28 дней от начала/смены терапии, затем 28; после кризиса PHQ-9 — 7,
PCL-5 — 14 с недельным периодом; специалист — 14/30; самопомощь — 28/28;
RUS-AUDIT-S — 30. При пересечении выбирается ближайшая дата. ЧМТ и алкогольные
направления сохраняются параллельно основному.
- Повторные PHQ/PCL сохраняют маршрут и изменение относительно последнего полного
результата той же версии и периода. Рост ≥5/≥10 помечается для повторного триажа.
Недельный PCL-5 не сравнивается с месячным. Переносимость уточняется при повторе
на фоне зафиксированной терапии.
## Остановка и работа врача
Положительный PHQ-9/9, высокий сигнал C-SSRS, мысли о смерти в дневнике и кнопка
помощи немедленно останавливают обычные вопросы. Сохраняются частичные ответы,
причина, дополнительные флаги и обращение. Кризисный ответ содержит 112 и кнопку
плана безопасности. Средний риск C-SSRS требует оценки специалиста; низкий при
утверждённой политике `graded` показывает план безопасности и обращение врачу.
Альтернатива `stop_any` прекращает опрос при первом «Да». Выбор остаётся за врачом.
**Русский Since Last Contact не получен.** При повторном мониторинге создаётся
отдельное задание для оценки специалистом; Recent не переименовывается и не
выдаётся за повторную форму. Перед дневником/плановым мониторингом предусмотрено
одно такое задание на местный календарный день. Обычный сценарий ждёт решения
врача. Это явно обозначенный временный порядок, который также требует согласования.
Дневниковое «Да» не ждёт окончания дневника или этой плановой процедуры.
Новые служебные маршруты:
- `GET /api/v1/patients/{token}/questionnaires?doctor_id=...` — ответы, версия,
периоды и результаты; чтение записывается в аудит.
- `POST /api/v1/patients/{token}/questionnaires/{record_id}/review` — поля
`doctor_id`, `assessment_performed: true`, `decision: resume | keep_hold`,
`reference` (идентификатор решения, без ФИО/свободного медицинского текста).
Проверяются принадлежность пациента врачу, согласие и статус записи.
Решение сохраняется с датой и аудитом. При открытом кризисе продолжение запрещено.
Простое подтверждение получения уведомления не снимает этот запрет проверки.
Как и остальной API этой ветки, эти маршруты предназначены для синтетического
прототипа: `doctor_id` передаёт вызывающая сторона. Перед реальными данными нужна
аутентификация врача; существующая карточка дашборда остаётся отдельной заглушкой.
## Надёжность и данные
Три новые таблицы: `questionnaire_records`, `questionnaire_notices`,
`questionnaire_tasks`. Они создаются существующим `init_db`; прежние результаты
шкал не переименовываются и не мигрируют. Ответ, переход и обращение фиксируются
в одной транзакции. Неполные суммы — `null`, C-SSRS не получает суммарного балла.
Выведенный п.2 RUS-AUDIT отмечается в `derived`, отсутствуя среди исходных ответов.
Период привязан к началу конкретного заполнения; у каждого ответа есть время.
Повторное нажатие и кнопка от другого вопроса не меняют следующий ответ.
Приостановленный опрос восстанавливается по сохранённой записи; смена версии
переводит его на проверку. После кризиса автоматического продолжения нет.
Отзыв согласия отменяет незавершённые записи, задания и неотправленные обращения.
Обращения врачу повторяются после ошибки с ограниченной задержкой; в ошибках
сохраняется только класс исключения. `accepted_by_adapter` означает приём адаптером,
`test_sink` — сохранение демонстрационным адаптером. Доставка человеку не утверждается.
Исходящие плановые сообщения сохраняются в задании до попытки отправки. Протокол
канала не поддерживает ключ идемпотентности: после падения между отправкой и
фиксацией возможна повторная доставка; потеря очереди внутри MemoryOutbox после
перезапуска также не является подтверждённой доставкой. Для реального запуска
нужен постоянный адаптер с подтверждением доставки.
Планировщик использует часы приложения и Europe/Moscow. PostgreSQL блокирует
параллельные запуски планировщика advisory lock, ответы пациента — блокировкой
строки. В SQLite демонстрации используется один процесс. Пропущенные тики не
создают пачку исторических анкет. Перезапуск не создаёт новое открытое задание
того же типа. Изменение схемы лекарств должно иметь новую дату начала в источнике
назначений; изменение дозировки без даты не угадывается.
## Предложенные определения для согласования
В `config/intake_runtime.yaml`: семь полных календарных дней ДО трёх дней ухудшения
образуют базовый уровень; пропуски не заменяются нулями. Для каждой из трёх ночей
проверяется «сон <4 часов ИЛИ кошмары ≥3». Только явное «не принимал» три дня подряд
считается пропуском; частичный приём и побочные эффекты — отдельные флаги.
Два дня без завершённого дневника в группе риска создают обращение для контакта;
сам звонок этим кодом не выполняется. При двух модулях подробные вопросы ПТСР и депрессии чередуются по календарным
дням; вопросы о риске остаются ежедневно. Обещание заполнить за одну минуту не используется. Якоря положительных показателей
и разбиение вопроса о сне — кандидатные формулировки для врача.
## Включение и остающиеся внешние действия
По умолчанию `enabled: false`; все подписи пусты. Для включения нужны решение по
C-SSRS, порог расширения алкоголя (сумма ИЛИ зона), подпись политики и три подписи
той же версии runtime: маршрутизация/дневники/расписания, содержание, контакты.
Тестовые подписи создаются только во временных каталогах тестов. После изменения
конфигурации требуется перезапуск приложения.
До работы с пациентами остаются: врачебное согласование, независимая проверка
перевода, проверка актуальной редакции КР и условий использования TSQ, получение
русского Since Last Contact, операционная проверка линий и готовности дежурного.
Публикация PR не выполняет ни один из этих пунктов.
Binary file not shown.
+203
View File
@@ -0,0 +1,203 @@
# Опросник: пакет проверки от 3 октября 2026
Статус: **подготовлено для проверки, не утверждено для пациентов**. Задача #52;
клиническое согласование остаётся в #8. Независимая проверка обратного перевода, согласование
врачом и проверка линий звонками не выполнены. В `review-status.json` все эти
статусы оставлены незавершёнными.
## Визуальный отчёт по PR #53
[Скачать PDF: проделанная работа и архитектура](PR53-implementation-report.pdf).
Восемь страниц: состав изменений, архитектура, опросы, расписания, хранение данных,
проверки и открытые вопросы. Срез коммита `c42ef62`; в отчёте отдельно отмечено
расхождение расписания для маршрута «психолог» (14/30 вместо 28/28 дней).
## Обновление: PDF на 19 страницах
Получена дополненная версия от 3 октября. [Разбор изменений](revision-2026-10-03-v2/REVIEW.md)
и [рабочий обратный перевод](revision-2026-10-03-v2/backtranslation-draft.md) сохранены
отдельно. TSQ заменил симптомные вопросы PC-PTSD-5; добавлены дневник и расписания.
**Новая спецификация подключена к диалогу за закрытым переключателем.**
[Состав реализации, ограничения и порядок проверки](IMPLEMENTATION.md).
Текст TSQ получен отдельно из приложения Г1 клинических рекомендаций 2023 года;
актуальность этой редакции ещё нужно подтвердить.
Независимая проверка перевода остаётся pending.
Описания реализованных правил и ссылки на страницы ниже относятся к исходному
14-страничному PDF, если явно не указана новая версия.
## Что реализовано
`app/core/questionnaire.py` содержит детерминированные правила первичного опроса:
короткий PHQ-2 из первых двух ответов PHQ-9, фильтр травмы PC-PTSD-5,
условное дополнение до полных шкал, RUS-AUDIT-S, структуру C-SSRS Recent,
приоритет основного маршрута и параллельные направления. Правила подключены к версионированному диалогу, дневнику и планировщику.
Замороженные контракты, `config/modules.yaml` и исторические результаты не менялись.
Клиническое включение заблокировано до подписей.
`config/questionnaire.yaml` фиксирует **кандидатные** пороги из PDF. Политика без
записи врача не выдаёт обычный маршрут. Положительный PHQ-9/9, известный высокий
сигнал C-SSRS и ручной запрос помощи дают `crisis` независимо от этого ограничения.
`clinical_review_required` при других положительных C-SSRS означает остановку
обычного потока и необходимость человеческой проверки, а не присвоение высокого
риска. Для неполного C-SSRS возвращается нижняя граница риска, итоговый риск пуст.
Использовать в адаптере `score_alcohol`, а не низкоуровневый `scales.score` для
маршрутизации: первый не выдаёт итоговую сумму/зону неполной анкеты. Известный пол
нужен только для интерпретации алкогольной шкалы; неизвестный не заменяется мужским
или женским. Нет ответа, отказ и технически пропущенный вопрос не равны нулю.
## RUS-AUDIT-S: исправление спецификации
В PDF на с.7–9 первые три пункта ошибочно названы RUS-AUDIT-S. Валидированная
краткая форма состоит из **пунктов 3, 9, 10** полного RUS-AUDIT, в этом порядке.
Пункт 3 имеет значения 0/1/2/3/4, пункты 9 и 10 — только 0/2/4; сумма 0–12.
| Зона | Женщины | Мужчины |
|---|---|---|
| I | 0–1 | 0–3 |
| II | 2–4 | 4–6 |
| III | 5 | 7 |
| IV | 6–12 | 8–12 |
Источник состава: [Neufeld et al., Bulletin of WHO, 2021](https://pmc.ncbi.nlm.nih.gov/articles/PMC8243036/).
Зоны и текст для самозаполнения: [Бунова и соавт., 2022, рис.3](https://www.mediasphera.ru/issues/profilakticheskaya-meditsina/2022/8/1230549482022081021),
DOI 10.17116/profmed20222508121. Рисунок визуально проверен, включая пояснение
слова «травма» во втором вопросе. В интерфейсе это пояснение нельзя опускать.
При дополнении до полной шкалы остаются пункты 1,2,4,5,6,7,8. Ответы на 3,9,10
переиспользуются по исходным ID и не суммируются дважды. Записывать версию и факт
получения ответа в краткой форме: её пояснение травмы отличается от полного текста.
Правило PDF `п.1 = 0` в новой версии выводит ноль только для 2; исходный 3 сохраняется, как и 9/10;
выведенные ответы возвращаются отдельно в `derived`, исходный словарь не меняется.
Полная форма и её зоны взяты из пользовательского PDF с.7–9, транскрипция является
материалом для согласования, не независимо подтверждённым русским бланком.
Для пункта 2 адаптеру нужна исходная таблица стандартных порций (с.8), а не
произвольное округление смешанных напитков. Применение полной интервью-формы
как самоопроса требует отдельного решения врача.
RUS-AUDIT: © ВОЗ, 2021; [отчёт ВОЗ](https://www.who.int/europe/publications/i/item/9789289055765),
CC BY-NC-SA 3.0 IGO. Использование в этом пакете — некоммерческий прототип.
Наличие шкалы здесь не означает одобрения продукта ВОЗ или авторами.
## C-SSRS: русский бланк Recent
Официальная [страница переводов Columbia](https://cssrs.columbia.edu/the-columbia-scale-c-ssrs/translations/)
ссылается на [Dropbox Screeners](https://www.dropbox.com/scl/fo/pkg1gwxxn1266rke8oite/AC2UFfOKCtefU-o9EEXP-yk?dl=0&rlkey=4grouuqbchfa8obxlcb3msnpz).
3 октября пользователь предоставил локальный `C-SSRS Screener - Russian.pdf`.
Неизменённая копия — `cssrs-screener-russian.pdf`, одна страница, заголовок
**Screen Version - Recent**. Вопросы, пояснения, периоды, варианты и цветовые
категории сверены с отрисованной страницей. Хеши оригинала и извлечённого текста
сохранены в `review-status.json`. Файл получен от пользователя после блокировки
загрузки; совпадение байтов с удалённой копией Dropbox независимо не проверено.
`config/instruments/cssrs_recent_ru.yaml` содержит отдельную транскрипцию,
`app/core/cssrs_content.py` — типизированный загрузчик `load_cssrs_russian`.
Версия `source-2026-10-03` — локальная версия содержания, **не номер редакции
Columbia**. Даты в метаданных PDF не используются как дата клинической редакции.
Сохранены все формулировки и опечатки, включая «Последние 1 месяца»,
«ЕСЛИ ДА 2», «пытался повеситься» и повтор «запаслись таблетками».
Нормализованы только межсловные пробелы и переносы. Пояснения интервьюеру
хранятся отдельно от вопросов. Любое редактирование требует отдельного решения.
Переходы по бланку: q1/q2 всем; при q2=0 перейти к q6; при q2=1 спросить q3–q5;
q6 за всю жизнь всем; при положительном q6 уточнить последние три месяца.
Для q1–q5 период — последний месяц. Ответы «Да»/«Нет» кодируются 1/0;
это коды ответов, **не суммируемые баллы**.
| Положительный ответ | Цвет бланка | Категория |
|---|---|---|
| q1, q2 | жёлтый | low |
| q3, q6_lifetime | оранжевый | moderate |
| q4, q5, q6_recent | красный | high |
При нескольких положительных ответах используется максимальная категория.
Поведение ранее трёх месяцев не превращается в «риска нет».
Правила совпадают с [Recent Screener для первичной помощи](https://cssrs.columbia.edu/wp-content/uploads/C-SSRS-Screener-with-Triage-Points-for-Primary-Care-2026.pdf)
и [описанием триажа Columbia](https://cssrs.columbia.edu/the-columbia-scale-c-ssrs/risk-identification/).
Неполные или противоречивые данные не получают итоговую категорию;
известный сигнал сохраняется как нижняя граница риска.
`config/scales/cssrs_screen.yaml` остаётся историческим демо-переводом. Его q6
спрашивает только о последних трёх месяцах; автоматически конвертировать такой
ответ в пожизненный нельзя. Новый бланк расположен вне `config/scales`, поскольку
действующий последовательный диалог не поддерживает его условные переходы.
Он ещё не включён в пациентский сценарий. Загрузчик текста сам по себе не
разрешает продолжение опроса: адаптер должен учитывать `evaluate_intake`,
а порядок обязательных вопросов получать из `score_cssrs_recent`.
Бланк не разрешает противоречие об остановке при любом «Да»: C01 остаётся
решением врача. Также нужны C06 (возраст/популяция, формат применения, обучение
и повторные замеры) и согласование пациентского сценария. Версию «с последнего
контакта» нельзя получить простой заменой заголовка Recent. Статус лицензии и
применимость к коммерческому продукту подтверждаются отдельно.
## Обратный перевод и независимая проверка
Для новой версии подготовлено 32 единицы: 28 PCL-5 и 4 из сохранённого блока
травмы. Русский текст сверён с PDF с.3 и 5–7. Перевод подготовлен как рабочий
вариант и требует независимой проверки. Результат и последующая сверка
с оригиналом разделены в `revision-2026-10-03-v2/`.
Независимому человеку передать **только**
`revision-2026-10-03-v2/backtranslation-blind.tsv`:
1. Переводчик со свободным английским не должен быть автором исходного русского
текста. Перевести все 32 единицы, не читая английский оригинал и подготовленный рабочий перевод.
2. Сохранить ID и порядок. Заполнить перевод, сомнения, имя и дату; русский текст
не исправлять. Если оригинал уже знаком, явно указать это ограничение.
3. Координатор и врач затем заполняют согласованный русский текст, решение,
подпись и дату в `revision-2026-10-03-v2/translation-reconciliation.tsv`.
Предварительные замечания в нём являются предложениями для проверки.
Исходные файлы `backtranslation-blind.tsv` и `translation-reconciliation.tsv`
в этой папке сохраняются как пакет прежней версии на 38 единиц. Пять симптомных
пунктов PC-PTSD-5 и его инструкция больше не входят в актуальный пакет.
Обратный перевод сам по себе не доказывает
психометрическую валидность. Клинические данные переводчикам не передаются.
## Решения врача — поля намеренно не заполнены
| ID | Решение, которое нужно зафиксировать | Статус |
|---|---|---|
| C01 | Любой «Да» останавливает обычный опрос или разрешено продолжение после низкого риска? Различить обычный опрос, уточнение риска и кризисную помощь. Новый PDF с.1 против с.12; в старом с.1 против с.11. Скриншот поддерживает остановку. | pending |
| C02 | Использовать сумму ≥1 или согласованную границу зоны для расширения RUS-AUDIT-S? Не переносить порог AUDIT-C ≥3/≥4. | pending |
| C03 | Самозаполнение полного RUS-AUDIT, таблица стандартных порций, порядок дополнения и повторные замеры. | pending |
| C04 | Новая спецификация: TSQ ≥6 вместо PC-PTSD-5 ≥3; получить приложение Г1, согласовать применение и PCL-5 ≥31 для популяции. TSQ из приложения Г1 редакции 2023 года уже добавлен; актуальность редакции и применение требуют подтверждения. | pending |
| C05 | Привязка PHQ-9/PCL-5 к специалистам, сроки срочной помощи, параллельные маршруты и состав флагов. | pending |
| C06 | Официальный русский C-SSRS: версия, возраст/популяция, применение Recent и Since Last Contact. | pending |
| C07 | Как отвечает живой дежурный, что делать при недоступности, когда подтверждать уведомление врачу. | pending |
| C08 | Повторы, ухудшение, разные периоды оценки и выход из кризисного режима; расписание после смены лекарств 7/14/28 дней и далее 4 недели. | pending |
| C09 | Дневник: якоря ответов, направление показателей, пропуски, окно среднего, ночные флаги, приверженность и сроки реакции. См. разбор новой версии. | pending |
Форма решения: ID; выбранный вариант; обоснование; ФИО/роль врача; дата с часовым
поясом; версия политики; ссылка на подписанный протокол; проверенные сценарии.
После решений C01/C02 и утверждения остальных применяемых порогов заполнить
`approval` в конфиге и изменить версию. Это запись о реальном согласовании, а не
переключатель для снятия блокировки. Синтетические записи в тестах не являются
подписью врача.
## Подключение к диалогу
Адаптер проверяет непосредственные сигналы после каждого ответа и на кнопке помощи;
при завершении вызывает `evaluate_intake` для общего маршрута.
`crisis` открывает фиксированную кризисную ветку до LLM; `clinical_review_required`
не разрешает автоматическое продолжение; `incomplete` запрашивает обязательные
пропуски; `ready` содержит основной маршрут и отдельные параллельные направления.
Кнопка помощи доступна на каждом экране. Не утверждать, что врач уведомлён,
пока интеграция не подтвердила доставку. Обычный опрос после кризиса сам не возобновляется.
Хранить ответы, версию шкалы/политики, период, время из `app.core.clock`, исходные
и выведенные значения раздельно, флаги и состояние полноты. Новые ключи C-SSRS
не подменяют старый cssrs_6. Нельзя сравнивать динамику разных версий и периодов
как одну и ту же шкалу. Клинические данные не отправляются переводчикам.
## Проверка
`make lint && make test`. Новые тесты проверяют все 45 допустимых наборов
RUS-AUDIT-S для каждого пола, границы полной формы, отрицательную и положительную
ветви C-SSRS, давнее поведение, пропуски/противоречия, кризис на неполном опросе,
конфликт PHQ-9/9 и C-SSRS, пороги маршрутов, независимые направления и отсутствие
разрешения на автоматический переход без реального решения врача.
Тесты C-SSRS дополнительно восстанавливают полный текст страницы из структуры,
проверяют хеши и сверяют все комбинации ветвей с цветовыми категориями бланка.
+39
View File
@@ -0,0 +1,39 @@
"scale" "item_id" "source_page" "russian_text" "english_backtranslation" "translator_comment" "translator_name" "completed_at"
"pcptsd5" "introduction" "3" "Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства." "" "" "" ""
"pcptsd5" "trauma_filter" "3" "Случалось ли с Вами подобное событие?" "" "" "" ""
"pcptsd5" "instruction" "3" "За последний месяц Вы…" "" "" "" ""
"pcptsd5" "pcptsd5_1" "4" "…видели кошмары об этом событии (событиях) или думали о нём, когда не хотели этого?" "" "" "" ""
"pcptsd5" "pcptsd5_2" "4" "…изо всех сил старались не думать о событии (событиях) или специально избегали ситуаций, которые о нём напоминали?" "" "" "" ""
"pcptsd5" "pcptsd5_3" "4" "…были постоянно настороже, начеку или легко вздрагивали?" "" "" "" ""
"pcptsd5" "pcptsd5_4" "4" "…чувствовали оцепенение или отстранённость от людей, занятий или того, что Вас окружает?" "" "" "" ""
"pcptsd5" "pcptsd5_5" "4" "…чувствовали вину или не могли перестать обвинять себя или других в событии (событиях) или в проблемах, которые оно могло вызвать?" "" "" "" ""
"pcptsd5" "option_Да" "3–4" "Да" "" "" "" ""
"pcptsd5" "option_Нет" "3–4" "Нет" "" "" "" ""
"pcl5" "instruction" "5" "Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц." "" "" "" ""
"pcl5" "optional_event" "5" "Ваше самое тяжёлое событие: ______" "" "" "" ""
"pcl5" "stem" "5" "Насколько сильно за последний месяц Вас беспокоили:" "" "" "" ""
"pcl5" "pcl5_1" "5" "Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?" "" "" "" ""
"pcl5" "pcl5_2" "5" "Повторяющиеся тяжёлые сны о стрессовом событии?" "" "" "" ""
"pcl5" "pcl5_3" "5" "Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?" "" "" "" ""
"pcl5" "pcl5_4" "5" "Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?" "" "" "" ""
"pcl5" "pcl5_5" "5" "Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?" "" "" "" ""
"pcl5" "pcl5_6" "5" "Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?" "" "" "" ""
"pcl5" "pcl5_7" "6" "Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?" "" "" "" ""
"pcl5" "pcl5_8" "6" "Трудности с тем, чтобы вспомнить важные части стрессового события?" "" "" "" ""
"pcl5" "pcl5_9" "6" "Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?" "" "" "" ""
"pcl5" "pcl5_10" "6" "Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?" "" "" "" ""
"pcl5" "pcl5_11" "6" "Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?" "" "" "" ""
"pcl5" "pcl5_12" "6" "Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?" "" "" "" ""
"pcl5" "pcl5_13" "6" "Ощущение отдалённости или оторванности от других людей?" "" "" "" ""
"pcl5" "pcl5_14" "6" "Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?" "" "" "" ""
"pcl5" "pcl5_15" "6" "Раздражительность, вспышки гнева или агрессивное поведение?" "" "" "" ""
"pcl5" "pcl5_16" "6" "Чрезмерный риск или поступки, которые могут причинить Вам вред?" "" "" "" ""
"pcl5" "pcl5_17" "6" "Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?" "" "" "" ""
"pcl5" "pcl5_18" "6" "Взвинченность или склонность легко вздрагивать?" "" "" "" ""
"pcl5" "pcl5_19" "6" "Трудности с концентрацией внимания?" "" "" "" ""
"pcl5" "pcl5_20" "7" "Трудности с засыпанием или прерывистый сон?" "" "" "" ""
"pcl5" "option_0" "5" "Совсем нет" "" "" "" ""
"pcl5" "option_1" "5" "Немного" "" "" "" ""
"pcl5" "option_2" "5" "Умеренно" "" "" "" ""
"pcl5" "option_3" "5" "Сильно" "" "" "" ""
"pcl5" "option_4" "5" "Крайне сильно" "" "" "" ""
1 scale item_id source_page russian_text english_backtranslation translator_comment translator_name completed_at
2 pcptsd5 introduction 3 Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства.
3 pcptsd5 trauma_filter 3 Случалось ли с Вами подобное событие?
4 pcptsd5 instruction 3 За последний месяц Вы…
5 pcptsd5 pcptsd5_1 4 …видели кошмары об этом событии (событиях) или думали о нём, когда не хотели этого?
6 pcptsd5 pcptsd5_2 4 …изо всех сил старались не думать о событии (событиях) или специально избегали ситуаций, которые о нём напоминали?
7 pcptsd5 pcptsd5_3 4 …были постоянно настороже, начеку или легко вздрагивали?
8 pcptsd5 pcptsd5_4 4 …чувствовали оцепенение или отстранённость от людей, занятий или того, что Вас окружает?
9 pcptsd5 pcptsd5_5 4 …чувствовали вину или не могли перестать обвинять себя или других в событии (событиях) или в проблемах, которые оно могло вызвать?
10 pcptsd5 option_Да 3–4 Да
11 pcptsd5 option_Нет 3–4 Нет
12 pcl5 instruction 5 Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц.
13 pcl5 optional_event 5 Ваше самое тяжёлое событие: ______
14 pcl5 stem 5 Насколько сильно за последний месяц Вас беспокоили:
15 pcl5 pcl5_1 5 Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?
16 pcl5 pcl5_2 5 Повторяющиеся тяжёлые сны о стрессовом событии?
17 pcl5 pcl5_3 5 Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?
18 pcl5 pcl5_4 5 Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?
19 pcl5 pcl5_5 5 Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?
20 pcl5 pcl5_6 5 Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?
21 pcl5 pcl5_7 6 Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?
22 pcl5 pcl5_8 6 Трудности с тем, чтобы вспомнить важные части стрессового события?
23 pcl5 pcl5_9 6 Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?
24 pcl5 pcl5_10 6 Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?
25 pcl5 pcl5_11 6 Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?
26 pcl5 pcl5_12 6 Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?
27 pcl5 pcl5_13 6 Ощущение отдалённости или оторванности от других людей?
28 pcl5 pcl5_14 6 Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?
29 pcl5 pcl5_15 6 Раздражительность, вспышки гнева или агрессивное поведение?
30 pcl5 pcl5_16 6 Чрезмерный риск или поступки, которые могут причинить Вам вред?
31 pcl5 pcl5_17 6 Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?
32 pcl5 pcl5_18 6 Взвинченность или склонность легко вздрагивать?
33 pcl5 pcl5_19 6 Трудности с концентрацией внимания?
34 pcl5 pcl5_20 7 Трудности с засыпанием или прерывистый сон?
35 pcl5 option_0 5 Совсем нет
36 pcl5 option_1 5 Немного
37 pcl5 option_2 5 Умеренно
38 pcl5 option_3 5 Сильно
39 pcl5 option_4 5 Крайне сильно
+43
View File
@@ -0,0 +1,43 @@
# Проверка контактов: 3 октября 2026
Проверены официальные страницы, **не телефонное соединение**. Никаких звонков
или уведомлений службам/врачу не выполнялось.
| Служба | Номер | Область применения | Что подтверждено сайтом |
|---|---|---|---|
| Экстренные службы | 112 | Россия, непосредственная угроза жизни | Круглосуточно и бесплатно: [МЧС](https://76.mchs.gov.ru/deyatelnost/poleznaya-informaciya/rekomendacii-naseleniyu/sistema-112) |
| Психологическая помощь МЧС | +7 (495) 989-50-50 | Психологическая помощь; московский городской номер | Номер опубликован [самой службой](https://psi.mchs.gov.ru/). Бесплатность звонка и доступность из конкретного региона не подтверждались. |
| Московская служба психологической помощи | 051 с городского; 8 (495) 051 с мобильного | Москва, региональный ресурс | [Страница службы](https://msph.ru/uslugi/neotlozhnaya-psikhologicheskaya-pomoshch): круглосуточно; городской звонок бесплатный, мобильный тарифицируется оператором. Не преобразовывать необычный короткий номер в выдуманный E.164. |
| Детский телефон доверия | 8-800-2000-122 | Дети, подростки и родители по вопросам ребёнка | [Официальная страница](https://telefon-doveria.ru/about/): аудитория, анонимность и бесплатность. Не универсальная взрослая кризисная линия. Часы работы конкретного регионального подразделения нужно уточнить. |
В `config/texts/crisis.yaml` универсальная взрослая ветка исправлена на линию МЧС;
детский номер сохранён в возрастной ветке с указанием аудитории. Безусловное
обещание уведомления врача удалено. Формулировки и готовность линий требуют
согласования перед включением нового опросника.
## Дополнения из новой версии PDF
| Ресурс | Проверка источника | Ограничение |
|---|---|---|
| МГППУ, 8-800-600-31-14 | [Сообщение МГППУ](https://mgppu.ru/news/10402) публикует номер для детей и родителей | Не включён как универсальная линия для всех взрослых; часы и доступность проверить у службы. |
| Санкт-Петербург, 8 (812) 708-40-41 | [Справочник администрации](https://www.gov.spb.ru/gov/admgub/obrasheniya/faq/13551/) указывает телефон Института «Гармония» для детей и взрослых | Региональный ресурс; охват Ленинградской области отдельно не подтверждён. Повторное открытие страницы дало ошибку; перед включением перепроверить. |
| Фонд «Защитники Отечества», 117, доб.1 | [Страница государственного медучреждения](https://xn--7--9kcgbq1fn7g.xn--p1ai/информация-для-участников-специальн/) указывает номер фонда | Сайт самого фонда при проверке недоступен. Номер поддержки ветеранов и семей не объявляется круглосуточной экстренной психиатрической линией. В активный кризисный экран не добавлен. |
Звонки по этим трём позициям также не выполнены.
Номер 112 проверять тестовым вызовом нельзя. Его назначение — реальная экстренная
ситуация. Технический тест `tel:112` проводить без совершения вызова. Для прочих
линий согласовать с оператором службы проверку доступности без имитации кризиса.
## Журнал операционной проверки (не выполнена)
| Служба | Проверяющий | Дата/часовой пояс | Регион/оператор связи | Доступность/часы/стоимость | Альтернативный ресурс | Результат |
|---|---|---|---|---|---|---|
| МЧС | | | | | | pending |
| МСППН | | | | | | pending |
| Детский телефон доверия | | | | | | pending |
Отдельно подтвердить наличие живого дежурного в самом продукте и время реакции.
Кнопка, мок или очередь уведомлений не являются подтверждённым контактом с человеком.
За пределами России приложение должно уточнять страну и давать местные экстренные
ресурсы, а не обещать доступность российских коротких номеров.
Binary file not shown.
@@ -0,0 +1 @@
КОЛУМБИЙСКАЯ ШКАЛА ОЦЕНКИ ТЯЖЕСТИ СУИЦИДА Screen Version - Recent Последние 1 месяца Задайте вопросы 1 и 2 Да Нет 1. Возникало ли у Вас желание умереть или заснуть и не просыпаться? 2. Думали ли Вы в самом деле о том, чтобы убить себя? ЕСЛИ ДА 2, Задайте вопросы 3, 4, 5 и 6. ЕСЛИ Нет, Задайте вопросы 6. 3. Думали ли Вы о том, как Вы могли бы это сделать? Пациент подтверждает, что его посещают мысли о самоубийстве и что в течение периода наблюдения он/она думал(-а) хотя бы об одном способе самоубийства. Данная ситуация отлична от такой, когда пациент разработал конкретный план: время, место или детали метода самоубийства (например, пациент думал о способе самоубийства, но не о конкретном плане). Сюда относятся люди, которые скажут: “Я думал(-а) о передозировке таблеток, но так и не построил(-а) конкретного плана о том, когда, где и как осуществить это…..и я бы никогда не довёл(-а) дело до конца”. 4. Посещали ли Вас подобные мысли, и было ли у Вас какое-нибудь намерение действовать в соответствии с ними? Активные мысли о самоубийстве при заявлении пациента о некотором намерении действовать в соответствии с этими мыслями, в отличие от подобного утверждения: “Такие мысли посещают меня, но я определенно не стану ничего предпринимать”. 5. Начали ли Вы разрабатывать или уже разработали детальный план самоубийства? Намереваетесь ли Вы осуществить этот план? 6. Вы когда-нибудь что-нибудь делали, начинали что-то делать или готовились сделать что-нибудь, чтобы покончить с собой? Примеры: запаслись таблетками, приобрели пистолет, раздали ценные вещи, написали завещание или предсмертную записку, запаслись таблетками, но не проглотили их, держали пистолет, но передумали стрелять или его кто-то выхватил из вашей руки, поднялись на крышу, но не прыгнули; или принимали таблетки, пытались застрелиться, порезались, пытался повеситься и т. д. ЕСЛИ ДА, то было ли это в последние 3 месяца? Low Risk Moderate Risk High Risk
Binary file not shown.
+53
View File
@@ -0,0 +1,53 @@
{
"prepared_on": "2026-10-03",
"source_pdf_sha256": "cad6008e08b256bc39732432832e12692bb4d296e34a501ed5e2d3da02ba05de",
"source_pdf": "questionnaires-mvp-source.pdf",
"translation_status": "draft_completed_independent_review_pending",
"clinician_approval": null,
"phone_checks": "not_performed",
"cssrs_russian_pdf": {
"original_filename": "C-SSRS Screener - Russian.pdf",
"received": true,
"acquisition": "user_provided_local_file",
"received_on": "2026-10-03",
"file": "cssrs-screener-russian.pdf",
"sha256": "d1dc77068b73e83a0e7959681b0fe9f15775a8b99863cf0e11794308a4c77c85",
"source_text": "cssrs-screener-russian.source.txt",
"source_text_sha256": "89835360bd59c6ead1a56bec6f40c46b72f81165366c92f85ba5d25291748de7",
"text_verified": true,
"visual_verification": true,
"pages": 1,
"form_subtitle": "Screen Version - Recent",
"content_version": "source-2026-10-03",
"publisher_version": null,
"source": "https://cssrs.columbia.edu/the-columbia-scale-c-ssrs/translations/",
"remote_bytes_independently_verified": false,
"clinical_use_approved": false,
"note": "User supplied the file after the official-source download workflow was blocked. Local content and colors checked; publisher version is not inferred from PDF metadata."
},
"intake_release_ready": false,
"policy_version": "mvp-v2-review-2026-10-03",
"latest_specification": {
"source_pdf": "revision-2026-10-03-v2/questionnaires-mvp-v2.pdf",
"sha256": "b5e1cfd2b292526a8cec3bbb679294a90a446c4104a3615c0d8fcbf10e12fc0e",
"pages": 19,
"review": "revision-2026-10-03-v2/REVIEW.md",
"implemented": true,
"pending_changes": [],
"tsq_verbatim_source_verified": true,
"current_guideline_edition_verified": false,
"implementation_scope": "gated_intake_diaries_schedules_manual_followup_risk_review",
"implementation": "IMPLEMENTATION.md"
},
"backtranslation": {
"units": 32,
"result": "revision-2026-10-03-v2/backtranslation-draft.tsv",
"reconciliation": "revision-2026-10-03-v2/translation-reconciliation.tsv",
"clinician_approval": null,
"independent_review": "not_performed"
},
"cssrs_since_contact": {
"russian_source_received": false,
"fallback": "clinician_assessment_required_before_routine_monitoring"
}
}
@@ -0,0 +1,103 @@
# Новая версия PDF и обратный перевод
Проверено 3 октября 2026. Новая версия — 19 страниц вместо 14; исходный PDF
сохранён без изменений как `questionnaires-mvp-v2.pdf`. Хеши обоих источников —
в `source-manifest.json`. Все страницы новой версии прочитаны и просмотрены.
Это разбор спецификации и языкового материала, не клиническое согласование.
Технический этап выполнен после разбора: [реализация и ограничения](../IMPLEMENTATION.md).
Ниже сохранены замечания к источнику с обновлённым статусом реализации.
## Что изменилось и как это влияет на реализацию
| Раздел новой версии | Изменение | Состояние реализации / дальнейшее действие |
|---|---|---|
| с.1, 3–4, 13 | TSQ вместо пяти симптомных вопросов PC-PTSD-5: 10 ответов, последняя неделя, реакция не реже двух раз, расширение при ≥6 | Реализован TSQ из приложения Г1 редакции 2023 года: 10 пунктов, порог 6; прежние результаты не переименованы. |
| с.3 | Вступление и фильтр травмы PC-PTSD-5 сохранены; при «Нет» TSQ не задаётся | Записывать «не проводился по фильтру», не десять нулевых ответов. Вопрос о травме не является одиннадцатым пунктом TSQ. |
| с.4 | Темы TSQ прямо обозначены как ориентиры, а не готовые вопросы | Нельзя показывать таблицу как официальный опросник. Дословный текст получен из приложения Г1 редакции 2023 года; актуальность редакции ещё не подтверждена. |
| с.5–7 | Обосновано сохранение PCL-5 вместо IES-R; предложено сравнение на пилоте | 20 русских пунктов, инструкции и варианты PCL-5 не изменились; поменялось расположение на страницах. Пилот и валидация не выполнены. |
| с.8–10 | RUS-AUDIT-S исправлен на 3/9/10; повтор тоже краткой формой | Совпадает с уже реализованным составом. Временное расширение при любом ненулевом ответе не равно переходу с зоны II. Решение C02 остаётся открытым. |
| с.8 | При п.1=0 теперь явно пропускается только п.2 | Исправлено: выводится только п.2=0; п.3 сохраняется. Конфликт останавливает поток для проверки. |
| с.10 | В PDF по-прежнему не вписаны зоны краткой формы | В PR они уже есть по рис.3 Буновой: женщины 0–1 / 2–4 / 5 / ≥6; мужчины 0–3 / 4–6 / 7 / ≥8. Это не автоматическое утверждение порога расширения. |
| с.11–12 | Добавлена ссылка на русские бланки Columbia | Recent уже получен и сверён в PR. Для мониторинга требуется отдельный официальный Since Last Contact; текущий Recent его не заменяет. |
| с.13–14 | Контроль после начала/смены лекарств на 7-й, 14-й, 28-й день, затем каждые 4 недели; при пересечении расписаний — более частое | Расписания и объединение реализованы, вопрос о переносимости добавлен как кандидатный. Требуются согласование и корректные даты изменений назначений. |
| с.14–16 | Ежедневный дневник: ядро, модули ПТСР, депрессии и суицидальных мыслей; тренды и внеплановые опросы | Это новые авторские вопросы и правила, не валидированная сокращённая шкала. Добавлены за закрытым переключателем; определения и якоря ждут согласования. |
| с.16–17 | Добавлена таблица соответствия КР ПТСР ID 753 | Утверждения PDF об актуальной редакции, силе рекомендаций и применимости требуют проверки по первоисточнику; ссылка PDF ведёт на вторичный правовой сайт. |
| с.18–19 | Добавлены кризисные телефоны и аудитории | Это список кандидатов, а не результат проверки доступности. Проверка источников дополнена в ../crisis-contacts.md; проверка соединения не выполнена. |
## Противоречия и недостающие определения
1. **C01 остаётся:** с.1 требует прекращать опрос при любом «Да», с.12 разрешает
продолжать при низком риске. Нужно различить прекращение обычной анкеты,
продолжение оценки риска и немедленную помощь. Обновление PDF не является подписью врача.
2. **Число вопросов C-SSRS:** на с.11 написано «от 2 до 6», но по показанным
переходам q1, q2 и q6 обязательны: минимум три основных вопроса, максимум шесть,
плюс условное уточнение давности q6. В данных это до семи отдельных ответов.
3. **Дневник:** в таблице ПТСР представлены B, C и E, но нет пункта кластера D.
Его нельзя описывать как охватывающий все кластеры PCL-5 или заменяющий полную шкалу.
4. **Направление шкал дневника:** интерес, энергия и надежда сформулированы
положительно, подавленность — отрицательно. Нужны якоря 0/4 и 0/10 и явное
направление каждого показателя; общая сумма сейчас не определена.
5. **Среднее за семь дней:** неясно, включаются ли текущие три дня ухудшения,
сколько измерений достаточно, как учитывать пропуски и часовой пояс.
6. **Ночные флаги:** «кошмары ≥3 или сон <4 часов три ночи подряд» допускает два
прочтения длительности. Нужна однозначная группировка условий.
7. **Лекарства и пропуски:** нужны «не назначены», несколько препаратов,
частичный приём и назначение по необходимости. Порог «2–3 дня без чек-ина»
должен стать точным правилом с ответственным и сроком реакции.
8. **Нагрузка:** ответ о сне содержит две величины; ядро с модулем и условными
уточнениями может превышать обещанные 8–10 ответов и одну минуту. Суицидальные
вопросы не должны исчезать из-за чередования модулей.
9. **Мониторинг:** определить периоды PCL-5 на каждом расписании, точку сравнения
для повторного триажа и действия при пересечении окон. Рост суммы разных
версий нельзя автоматически считать сопоставимым изменением.
Эти замечания являются выводами разбора, а не утверждёнными новыми правилами.
Числовые значения дневника пока нельзя переносить в работающий сценарий.
## Обратный перевод выполнен
Переведены 32 единицы: PCL-5 — инструкция, поле события, общий вопрос, 20 пунктов
и 5 вариантов ответа; блок травмы — вступление, фильтр и 2 варианта ответа.
Русские формулировки сверены с новой версией; исключены устаревшие пять вопросов
PC-PTSD-5 и его отдельная инструкция о месяце.
- `backtranslation-draft.md` — удобный для чтения русский/английский текст и комментарии.
- `backtranslation-draft.tsv` — зафиксированный результат, с датой подготовки; сведения о проверяющем заполняются при согласовании.
- `translation-reconciliation.tsv` — последующая предварительная сверка PCL-5 с оригиналом,
поля согласованного русского текста и подписи врача пусты.
- `backtranslation-blind.tsv` — отдельный незаполненный пакет для независимого человека.
Английский оригинал не открывался во время перевода. После сохранения результата
проведена отдельная сверка с [официальным бланком VA, с.3](https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf).
На обложке указано 29 августа 2023, в нижнем колонтитуле бланка — 18 августа 2023;
обе даты записаны без попытки выбрать одну. Четыре единицы блока травмы в этом
проходе с английским первоисточником не сопоставлялись.
Главные замечания к PCL-5 после сверки:
| Пункт | Что требует внимания |
|---|---|
| 1–2 | «Тяжёлые» допускает неоднозначное понимание эмоционального воздействия воспоминаний и снов. |
| 15 | «Раздражительность» может описывать состояние; английский пункт явно указывает раздражительное поведение. |
| 17 | «Всё время» добавляет требование постоянства. |
| 18 | «Взвинченность» может пониматься шире, чем повышенная пугливость. |
| 20 | Прерывистость сна и трудность сохранять сон близки, но не тождественны. |
| Ответ 3 | Русское «Сильно» требует проверки градации интенсивности относительно оригинала и соседних ответов. |
Русский текст не исправлялся. Перевод имеет статус рабочего варианта и требует
независимой проверки. Заключение о языковой эквивалентности, клиническое
согласование и психометрическая валидация не выполнены.
## Что остаётся после этого разбора
Техническая реализация новой версии подготовлена в PR #53. Полное описание —
[IMPLEMENTATION.md](../IMPLEMENTATION.md). Клиническое включение остаётся закрытым:
врачебные решения, независимая проверка перевода и операционная проверка линий
не выполнены; отдельная русская форма Since Last Contact не получена.
Попытка проверить актуальную карточку [КР 753_1](https://cr.minzdrav.gov.ru/preview-cr/753_1)
через веб-инструмент не дала доступного содержимого; актуальность редакции
на дату разбора не подтверждена. Утверждения PDF о рекомендациях не отмечены
как независимо проверенные. Наличие ссылки или таблицы не подтверждает
клиническое соответствие всего приложения.
@@ -0,0 +1,33 @@
"scale" "item_id" "source_page" "russian_text" "english_backtranslation" "translator_comment" "translator_name" "completed_at"
"trauma_filter" "introduction" "3" "Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства." "" "" "" ""
"trauma_filter" "trauma_filter" "3" "Случалось ли с Вами подобное событие?" "" "" "" ""
"trauma_filter" "option_Да" "3" "Да" "" "" "" ""
"trauma_filter" "option_Нет" "3" "Нет" "" "" "" ""
"pcl5" "instruction" "5" "Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц." "" "" "" ""
"pcl5" "optional_event" "5" "Ваше самое тяжёлое событие: ______" "" "" "" ""
"pcl5" "stem" "5" "Насколько сильно за последний месяц Вас беспокоили:" "" "" "" ""
"pcl5" "pcl5_1" "5" "Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?" "" "" "" ""
"pcl5" "pcl5_2" "6" "Повторяющиеся тяжёлые сны о стрессовом событии?" "" "" "" ""
"pcl5" "pcl5_3" "6" "Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?" "" "" "" ""
"pcl5" "pcl5_4" "6" "Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?" "" "" "" ""
"pcl5" "pcl5_5" "6" "Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?" "" "" "" ""
"pcl5" "pcl5_6" "6" "Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?" "" "" "" ""
"pcl5" "pcl5_7" "6" "Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?" "" "" "" ""
"pcl5" "pcl5_8" "6" "Трудности с тем, чтобы вспомнить важные части стрессового события?" "" "" "" ""
"pcl5" "pcl5_9" "6" "Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?" "" "" "" ""
"pcl5" "pcl5_10" "6" "Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?" "" "" "" ""
"pcl5" "pcl5_11" "6" "Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?" "" "" "" ""
"pcl5" "pcl5_12" "6" "Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?" "" "" "" ""
"pcl5" "pcl5_13" "6" "Ощущение отдалённости или оторванности от других людей?" "" "" "" ""
"pcl5" "pcl5_14" "7" "Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?" "" "" "" ""
"pcl5" "pcl5_15" "7" "Раздражительность, вспышки гнева или агрессивное поведение?" "" "" "" ""
"pcl5" "pcl5_16" "7" "Чрезмерный риск или поступки, которые могут причинить Вам вред?" "" "" "" ""
"pcl5" "pcl5_17" "7" "Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?" "" "" "" ""
"pcl5" "pcl5_18" "7" "Взвинченность или склонность легко вздрагивать?" "" "" "" ""
"pcl5" "pcl5_19" "7" "Трудности с концентрацией внимания?" "" "" "" ""
"pcl5" "pcl5_20" "7" "Трудности с засыпанием или прерывистый сон?" "" "" "" ""
"pcl5" "option_0" "5" "Совсем нет" "" "" "" ""
"pcl5" "option_1" "5" "Немного" "" "" "" ""
"pcl5" "option_2" "5" "Умеренно" "" "" "" ""
"pcl5" "option_3" "5" "Сильно" "" "" "" ""
"pcl5" "option_4" "5" "Крайне сильно" "" "" "" ""
1 scale item_id source_page russian_text english_backtranslation translator_comment translator_name completed_at
2 trauma_filter introduction 3 Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства.
3 trauma_filter trauma_filter 3 Случалось ли с Вами подобное событие?
4 trauma_filter option_Да 3 Да
5 trauma_filter option_Нет 3 Нет
6 pcl5 instruction 5 Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц.
7 pcl5 optional_event 5 Ваше самое тяжёлое событие: ______
8 pcl5 stem 5 Насколько сильно за последний месяц Вас беспокоили:
9 pcl5 pcl5_1 5 Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?
10 pcl5 pcl5_2 6 Повторяющиеся тяжёлые сны о стрессовом событии?
11 pcl5 pcl5_3 6 Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?
12 pcl5 pcl5_4 6 Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?
13 pcl5 pcl5_5 6 Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?
14 pcl5 pcl5_6 6 Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?
15 pcl5 pcl5_7 6 Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?
16 pcl5 pcl5_8 6 Трудности с тем, чтобы вспомнить важные части стрессового события?
17 pcl5 pcl5_9 6 Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?
18 pcl5 pcl5_10 6 Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?
19 pcl5 pcl5_11 6 Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?
20 pcl5 pcl5_12 6 Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?
21 pcl5 pcl5_13 6 Ощущение отдалённости или оторванности от других людей?
22 pcl5 pcl5_14 7 Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?
23 pcl5 pcl5_15 7 Раздражительность, вспышки гнева или агрессивное поведение?
24 pcl5 pcl5_16 7 Чрезмерный риск или поступки, которые могут причинить Вам вред?
25 pcl5 pcl5_17 7 Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?
26 pcl5 pcl5_18 7 Взвинченность или склонность легко вздрагивать?
27 pcl5 pcl5_19 7 Трудности с концентрацией внимания?
28 pcl5 pcl5_20 7 Трудности с засыпанием или прерывистый сон?
29 pcl5 option_0 5 Совсем нет
30 pcl5 option_1 5 Немного
31 pcl5 option_2 5 Умеренно
32 pcl5 option_3 5 Сильно
33 pcl5 option_4 5 Крайне сильно
@@ -0,0 +1,221 @@
# Обратный перевод: русский → английский
Источник: обновлённый PDF, 19 страниц, с.3 и 5–7. **Рабочий вариант, требует независимой проверки и согласования.**
Русский текст сохранён. 32 единицы: 28 PCL-5 и 4 из блока о травме. TSQ не включён: PDF содержит темы, а не дословные вопросы. Авторский дневник не имеет указанного английского оригинала, поэтому его перевод не является обратным.
Исходный результат зафиксирован в `backtranslation-draft.tsv`; последующая сверка с оригиналом ведётся отдельно. Пустой `backtranslation-blind.tsv` оставлен для независимого переводчика.
## trauma_filter / introduction · с. 3
Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства.
**EN:** Sometimes people experience events that are exceptionally frightening, horrifying, or traumatic. For example: a serious accident or fire; physical or sexual assault or violence; an earthquake or flood; war; witnessing someone being killed or seriously wounded; a loved one dying as a result of murder or suicide.
## trauma_filter / trauma_filter · с. 3
Случалось ли с Вами подобное событие?
**EN:** Has an event like this happened to you?
**Комментарий переводчика:** «С Вами» may be read as direct personal exposure despite witnessing and bereavement examples; retain the introduction when reviewing.
## trauma_filter / option_Да · с. 3
Да
**EN:** Yes
## trauma_filter / option_Нет · с. 3
Нет
**EN:** No
## pcl5 / instruction · с. 5
Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц.
**EN:** Listed below are problems that people sometimes experience after a very difficult, stressful event. Keeping in mind the event that was most difficult for you, read each item carefully and choose the answer that shows how much this problem bothered you over the past month.
**Комментарий переводчика:** «Тяжёлый» is interpreted as emotionally difficult, not physical weight. «Самое тяжёлое» is comparative; «держать в уме» means keep in mind.
## pcl5 / optional_event · с. 5
Ваше самое тяжёлое событие: ______
**EN:** The event that was most difficult for you: ______
## pcl5 / stem · с. 5
Насколько сильно за последний месяц Вас беспокоили:
**EN:** Over the past month, how much were you bothered by:
## pcl5 / pcl5_1 · с. 5
Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?
**EN:** Recurring distressing and unwanted memories of the stressful event?
**Комментарий переводчика:** «Тяжёлые» is interpreted as distressing; other readings include severe or difficult. No additional symptom descriptor was inserted.
## pcl5 / pcl5_2 · с. 6
Повторяющиеся тяжёлые сны о стрессовом событии?
**EN:** Recurring distressing dreams about the stressful event?
**Комментарий переводчика:** «Тяжёлые сны» is interpreted as distressing dreams; not automatically rewritten as nightmares.
## pcl5 / pcl5_3 · с. 6
Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?
**EN:** Suddenly feeling or acting as though the stressful event were happening again (as though you were actually back there, experiencing it all over again)?
## pcl5 / pcl5_4 · с. 6
Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?
**EN:** Becoming very upset when something reminded you of the stressful event?
**Комментарий переводчика:** «Расстройство» is interpreted as emotional upset, not a diagnosed disorder.
## pcl5 / pcl5_5 · с. 6
Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?
**EN:** Strong bodily reactions when something reminded you of the stressful event (for example, a racing heart, difficulty breathing, sweating)?
## pcl5 / pcl5_6 · с. 6
Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?
**EN:** Avoiding memories, thoughts, or feelings associated with the stressful event?
## pcl5 / pcl5_7 · с. 6
Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?
**EN:** Avoiding external reminders of the stressful event (for example, people, places, conversations, activities, objects, or situations)?
## pcl5 / pcl5_8 · с. 6
Трудности с тем, чтобы вспомнить важные части стрессового события?
**EN:** Difficulty remembering important parts of the stressful event?
## pcl5 / pcl5_9 · с. 6
Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?
**EN:** Strong negative beliefs about yourself, other people, or the world (for example, thoughts such as “I am bad,” “there is something seriously wrong with me,” “no one can be trusted,” “the world is completely dangerous”)?
**Комментарий переводчика:** «Мир абсолютно опасен» retains its categorical intensity; not softened to generally unsafe.
## pcl5 / pcl5_10 · с. 6
Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?
**EN:** Blaming yourself or someone else for the stressful event or for what happened afterward?
## pcl5 / pcl5_11 · с. 6
Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?
**EN:** Strong negative feelings such as fear, horror, anger, guilt, or shame?
## pcl5 / pcl5_12 · с. 6
Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?
**EN:** Losing interest in activities that used to give you pleasure?
## pcl5 / pcl5_13 · с. 6
Ощущение отдалённости или оторванности от других людей?
**EN:** Feeling distant or cut off from other people?
## pcl5 / pcl5_14 · с. 7
Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?
**EN:** Difficulty experiencing positive feelings (for example, being unable to feel joy or love for people close to you)?
**Комментарий переводчика:** «Близкие люди» means people close to the respondent, not necessarily relatives.
## pcl5 / pcl5_15 · с. 7
Раздражительность, вспышки гнева или агрессивное поведение?
**EN:** Irritability, outbursts of anger, or aggressive behavior?
## pcl5 / pcl5_16 · с. 7
Чрезмерный риск или поступки, которые могут причинить Вам вред?
**EN:** Excessive risk-taking or actions that could harm you?
**Комментарий переводчика:** «Чрезмерный риск» rendered as excessive risk-taking; actual injury is not implied.
## pcl5 / pcl5_17 · с. 7
Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?
**EN:** Increased vigilance, wariness, or a feeling that you need to be on guard all the time?
## pcl5 / pcl5_18 · с. 7
Взвинченность или склонность легко вздрагивать?
**EN:** Feeling keyed up or being easily startled?
**Комментарий переводчика:** «Взвинченность» can mean being keyed up, tense, or agitated; the broader Russian reading should be reviewed.
## pcl5 / pcl5_19 · с. 7
Трудности с концентрацией внимания?
**EN:** Difficulty concentrating?
## pcl5 / pcl5_20 · с. 7
Трудности с засыпанием или прерывистый сон?
**EN:** Difficulty falling asleep or interrupted sleep?
**Комментарий переводчика:** «Прерывистый сон» means interrupted sleep; no additional sleep condition was inserted.
## pcl5 / option_0 · с. 5
Совсем нет
**EN:** Not at all
## pcl5 / option_1 · с. 5
Немного
**EN:** A little
## pcl5 / option_2 · с. 5
Умеренно
**EN:** Moderately
## pcl5 / option_3 · с. 5
Сильно
**EN:** Very much
**Комментарий переводчика:** Intensity anchor translated as Very much; requires review together with the question stem and other anchors.
## pcl5 / option_4 · с. 5
Крайне сильно
**EN:** Extremely
@@ -0,0 +1,33 @@
"scale" "item_id" "source_page" "russian_text" "english_backtranslation" "translator_comment" "translator_name" "completed_at"
"trauma_filter" "introduction" "3" "Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства." "Sometimes people experience events that are exceptionally frightening, horrifying, or traumatic. For example: a serious accident or fire; physical or sexual assault or violence; an earthquake or flood; war; witnessing someone being killed or seriously wounded; a loved one dying as a result of murder or suicide." "" "" "2026-10-03"
"trauma_filter" "trauma_filter" "3" "Случалось ли с Вами подобное событие?" "Has an event like this happened to you?" "«С Вами» may be read as direct personal exposure despite witnessing and bereavement examples; retain the introduction when reviewing." "" "2026-10-03"
"trauma_filter" "option_Да" "3" "Да" "Yes" "" "" "2026-10-03"
"trauma_filter" "option_Нет" "3" "Нет" "No" "" "" "2026-10-03"
"pcl5" "instruction" "5" "Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц." "Listed below are problems that people sometimes experience after a very difficult, stressful event. Keeping in mind the event that was most difficult for you, read each item carefully and choose the answer that shows how much this problem bothered you over the past month." "«Тяжёлый» is interpreted as emotionally difficult, not physical weight. «Самое тяжёлое» is comparative; «держать в уме» means keep in mind." "" "2026-10-03"
"pcl5" "optional_event" "5" "Ваше самое тяжёлое событие: ______" "The event that was most difficult for you: ______" "" "" "2026-10-03"
"pcl5" "stem" "5" "Насколько сильно за последний месяц Вас беспокоили:" "Over the past month, how much were you bothered by:" "" "" "2026-10-03"
"pcl5" "pcl5_1" "5" "Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?" "Recurring distressing and unwanted memories of the stressful event?" "«Тяжёлые» is interpreted as distressing; other readings include severe or difficult. No additional symptom descriptor was inserted." "" "2026-10-03"
"pcl5" "pcl5_2" "6" "Повторяющиеся тяжёлые сны о стрессовом событии?" "Recurring distressing dreams about the stressful event?" "«Тяжёлые сны» is interpreted as distressing dreams; not automatically rewritten as nightmares." "" "2026-10-03"
"pcl5" "pcl5_3" "6" "Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?" "Suddenly feeling or acting as though the stressful event were happening again (as though you were actually back there, experiencing it all over again)?" "" "" "2026-10-03"
"pcl5" "pcl5_4" "6" "Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?" "Becoming very upset when something reminded you of the stressful event?" "«Расстройство» is interpreted as emotional upset, not a diagnosed disorder." "" "2026-10-03"
"pcl5" "pcl5_5" "6" "Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?" "Strong bodily reactions when something reminded you of the stressful event (for example, a racing heart, difficulty breathing, sweating)?" "" "" "2026-10-03"
"pcl5" "pcl5_6" "6" "Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?" "Avoiding memories, thoughts, or feelings associated with the stressful event?" "" "" "2026-10-03"
"pcl5" "pcl5_7" "6" "Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?" "Avoiding external reminders of the stressful event (for example, people, places, conversations, activities, objects, or situations)?" "" "" "2026-10-03"
"pcl5" "pcl5_8" "6" "Трудности с тем, чтобы вспомнить важные части стрессового события?" "Difficulty remembering important parts of the stressful event?" "" "" "2026-10-03"
"pcl5" "pcl5_9" "6" "Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?" "Strong negative beliefs about yourself, other people, or the world (for example, thoughts such as “I am bad,” “there is something seriously wrong with me,” “no one can be trusted,” “the world is completely dangerous”)?" "«Мир абсолютно опасен» retains its categorical intensity; not softened to generally unsafe." "" "2026-10-03"
"pcl5" "pcl5_10" "6" "Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?" "Blaming yourself or someone else for the stressful event or for what happened afterward?" "" "" "2026-10-03"
"pcl5" "pcl5_11" "6" "Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?" "Strong negative feelings such as fear, horror, anger, guilt, or shame?" "" "" "2026-10-03"
"pcl5" "pcl5_12" "6" "Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?" "Losing interest in activities that used to give you pleasure?" "" "" "2026-10-03"
"pcl5" "pcl5_13" "6" "Ощущение отдалённости или оторванности от других людей?" "Feeling distant or cut off from other people?" "" "" "2026-10-03"
"pcl5" "pcl5_14" "7" "Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?" "Difficulty experiencing positive feelings (for example, being unable to feel joy or love for people close to you)?" "«Близкие люди» means people close to the respondent, not necessarily relatives." "" "2026-10-03"
"pcl5" "pcl5_15" "7" "Раздражительность, вспышки гнева или агрессивное поведение?" "Irritability, outbursts of anger, or aggressive behavior?" "" "" "2026-10-03"
"pcl5" "pcl5_16" "7" "Чрезмерный риск или поступки, которые могут причинить Вам вред?" "Excessive risk-taking or actions that could harm you?" "«Чрезмерный риск» rendered as excessive risk-taking; actual injury is not implied." "" "2026-10-03"
"pcl5" "pcl5_17" "7" "Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?" "Increased vigilance, wariness, or a feeling that you need to be on guard all the time?" "" "" "2026-10-03"
"pcl5" "pcl5_18" "7" "Взвинченность или склонность легко вздрагивать?" "Feeling keyed up or being easily startled?" "«Взвинченность» can mean being keyed up, tense, or agitated; the broader Russian reading should be reviewed." "" "2026-10-03"
"pcl5" "pcl5_19" "7" "Трудности с концентрацией внимания?" "Difficulty concentrating?" "" "" "2026-10-03"
"pcl5" "pcl5_20" "7" "Трудности с засыпанием или прерывистый сон?" "Difficulty falling asleep or interrupted sleep?" "«Прерывистый сон» means interrupted sleep; no additional sleep condition was inserted." "" "2026-10-03"
"pcl5" "option_0" "5" "Совсем нет" "Not at all" "" "" "2026-10-03"
"pcl5" "option_1" "5" "Немного" "A little" "" "" "2026-10-03"
"pcl5" "option_2" "5" "Умеренно" "Moderately" "" "" "2026-10-03"
"pcl5" "option_3" "5" "Сильно" "Very much" "Intensity anchor translated as Very much; requires review together with the question stem and other anchors." "" "2026-10-03"
"pcl5" "option_4" "5" "Крайне сильно" "Extremely" "" "" "2026-10-03"
1 scale item_id source_page russian_text english_backtranslation translator_comment translator_name completed_at
2 trauma_filter introduction 3 Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства. Sometimes people experience events that are exceptionally frightening, horrifying, or traumatic. For example: a serious accident or fire; physical or sexual assault or violence; an earthquake or flood; war; witnessing someone being killed or seriously wounded; a loved one dying as a result of murder or suicide. 2026-10-03
3 trauma_filter trauma_filter 3 Случалось ли с Вами подобное событие? Has an event like this happened to you? «С Вами» may be read as direct personal exposure despite witnessing and bereavement examples; retain the introduction when reviewing. 2026-10-03
4 trauma_filter option_Да 3 Да Yes 2026-10-03
5 trauma_filter option_Нет 3 Нет No 2026-10-03
6 pcl5 instruction 5 Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц. Listed below are problems that people sometimes experience after a very difficult, stressful event. Keeping in mind the event that was most difficult for you, read each item carefully and choose the answer that shows how much this problem bothered you over the past month. «Тяжёлый» is interpreted as emotionally difficult, not physical weight. «Самое тяжёлое» is comparative; «держать в уме» means keep in mind. 2026-10-03
7 pcl5 optional_event 5 Ваше самое тяжёлое событие: ______ The event that was most difficult for you: ______ 2026-10-03
8 pcl5 stem 5 Насколько сильно за последний месяц Вас беспокоили: Over the past month, how much were you bothered by: 2026-10-03
9 pcl5 pcl5_1 5 Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии? Recurring distressing and unwanted memories of the stressful event? «Тяжёлые» is interpreted as distressing; other readings include severe or difficult. No additional symptom descriptor was inserted. 2026-10-03
10 pcl5 pcl5_2 6 Повторяющиеся тяжёлые сны о стрессовом событии? Recurring distressing dreams about the stressful event? «Тяжёлые сны» is interpreted as distressing dreams; not automatically rewritten as nightmares. 2026-10-03
11 pcl5 pcl5_3 6 Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)? Suddenly feeling or acting as though the stressful event were happening again (as though you were actually back there, experiencing it all over again)? 2026-10-03
12 pcl5 pcl5_4 6 Сильное расстройство, когда что-то напоминало Вам о стрессовом событии? Becoming very upset when something reminded you of the stressful event? «Расстройство» is interpreted as emotional upset, not a diagnosed disorder. 2026-10-03
13 pcl5 pcl5_5 6 Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)? Strong bodily reactions when something reminded you of the stressful event (for example, a racing heart, difficulty breathing, sweating)? 2026-10-03
14 pcl5 pcl5_6 6 Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием? Avoiding memories, thoughts, or feelings associated with the stressful event? 2026-10-03
15 pcl5 pcl5_7 6 Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)? Avoiding external reminders of the stressful event (for example, people, places, conversations, activities, objects, or situations)? 2026-10-03
16 pcl5 pcl5_8 6 Трудности с тем, чтобы вспомнить важные части стрессового события? Difficulty remembering important parts of the stressful event? 2026-10-03
17 pcl5 pcl5_9 6 Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)? Strong negative beliefs about yourself, other people, or the world (for example, thoughts such as “I am bad,” “there is something seriously wrong with me,” “no one can be trusted,” “the world is completely dangerous”)? «Мир абсолютно опасен» retains its categorical intensity; not softened to generally unsafe. 2026-10-03
18 pcl5 pcl5_10 6 Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него? Blaming yourself or someone else for the stressful event or for what happened afterward? 2026-10-03
19 pcl5 pcl5_11 6 Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд? Strong negative feelings such as fear, horror, anger, guilt, or shame? 2026-10-03
20 pcl5 pcl5_12 6 Потеря интереса к занятиям, которые раньше приносили Вам удовольствие? Losing interest in activities that used to give you pleasure? 2026-10-03
21 pcl5 pcl5_13 6 Ощущение отдалённости или оторванности от других людей? Feeling distant or cut off from other people? 2026-10-03
22 pcl5 pcl5_14 7 Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)? Difficulty experiencing positive feelings (for example, being unable to feel joy or love for people close to you)? «Близкие люди» means people close to the respondent, not necessarily relatives. 2026-10-03
23 pcl5 pcl5_15 7 Раздражительность, вспышки гнева или агрессивное поведение? Irritability, outbursts of anger, or aggressive behavior? 2026-10-03
24 pcl5 pcl5_16 7 Чрезмерный риск или поступки, которые могут причинить Вам вред? Excessive risk-taking or actions that could harm you? «Чрезмерный риск» rendered as excessive risk-taking; actual injury is not implied. 2026-10-03
25 pcl5 pcl5_17 7 Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку? Increased vigilance, wariness, or a feeling that you need to be on guard all the time? 2026-10-03
26 pcl5 pcl5_18 7 Взвинченность или склонность легко вздрагивать? Feeling keyed up or being easily startled? «Взвинченность» can mean being keyed up, tense, or agitated; the broader Russian reading should be reviewed. 2026-10-03
27 pcl5 pcl5_19 7 Трудности с концентрацией внимания? Difficulty concentrating? 2026-10-03
28 pcl5 pcl5_20 7 Трудности с засыпанием или прерывистый сон? Difficulty falling asleep or interrupted sleep? «Прерывистый сон» means interrupted sleep; no additional sleep condition was inserted. 2026-10-03
29 pcl5 option_0 5 Совсем нет Not at all 2026-10-03
30 pcl5 option_1 5 Немного A little 2026-10-03
31 pcl5 option_2 5 Умеренно Moderately 2026-10-03
32 pcl5 option_3 5 Сильно Very much Intensity anchor translated as Very much; requires review together with the question stem and other anchors. 2026-10-03
33 pcl5 option_4 5 Крайне сильно Extremely 2026-10-03
@@ -0,0 +1,27 @@
{
"source": "questionnaires-mvp-v2.pdf",
"sha256": "b5e1cfd2b292526a8cec3bbb679294a90a446c4104a3615c0d8fcbf10e12fc0e",
"pages": 19,
"previous_source": "../questionnaires-mvp-source.pdf",
"previous_sha256": "cad6008e08b256bc39732432832e12692bb4d296e34a501ed5e2d3da02ba05de",
"units": 32,
"scope": "PCL-5 (28 units) and retained trauma introduction/filter/options (4 units)",
"excluded": "TSQ contains topic outlines only; five removed PC-PTSD-5 items are obsolete; diary is original team content, not a backtranslation target",
"clinician_approval": null,
"reference_comparison": {
"performed_after_translation_frozen": true,
"scope": "28 PCL-5 units",
"reference": "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf",
"reference_page": 3,
"reference_cover_date": "2023-08-29",
"reference_form_footer_date": "2023-08-18",
"independent_review": "pending"
},
"independent_review": "not_performed",
"backtranslation": {
"file": "backtranslation-draft.tsv",
"sha256": "4e8bbcde4d6cb8b613a3f1224fa3963dc81922cabfa1fb1560eeeebf714b3ad5",
"status": "draft_completed_independent_review_pending",
"date": "2026-10-03"
}
}
@@ -0,0 +1,33 @@
"scale" "item_id" "source_page" "russian_text" "backtranslation" "english_original_reference" "review_flag" "review_note" "agreed_russian_text" "clinician" "decision_date" "status"
"trauma_filter" "introduction" "3" "Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства." "Sometimes people experience events that are exceptionally frightening, horrifying, or traumatic. For example: a serious accident or fire; physical or sexual assault or violence; an earthquake or flood; war; witnessing someone being killed or seriously wounded; a loved one dying as a result of murder or suicide." "" "not_compared" "Переведено по PDF; с английским источником фильтра в этом проходе не сверялось." "" "" "" "pending_independent_review"
"trauma_filter" "trauma_filter" "3" "Случалось ли с Вами подобное событие?" "Has an event like this happened to you?" "" "not_compared" "Переведено по PDF; с английским источником фильтра в этом проходе не сверялось." "" "" "" "pending_independent_review"
"trauma_filter" "option_Да" "3" "Да" "Yes" "" "not_compared" "Переведено по PDF; с английским источником фильтра в этом проходе не сверялось." "" "" "" "pending_independent_review"
"trauma_filter" "option_Нет" "3" "Нет" "No" "" "not_compared" "Переведено по PDF; с английским источником фильтра в этом проходе не сверялось." "" "" "" "pending_independent_review"
"pcl5" "instruction" "5" "Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц." "Listed below are problems that people sometimes experience after a very difficult, stressful event. Keeping in mind the event that was most difficult for you, read each item carefully and choose the answer that shows how much this problem bothered you over the past month." "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "Смысл инструкции и месячный период сохранены; «тяжёлый» допускает разные оттенки." "" "" "" "pending_independent_review"
"pcl5" "optional_event" "5" "Ваше самое тяжёлое событие: ______" "The event that was most difficult for you: ______" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "stem" "5" "Насколько сильно за последний месяц Вас беспокоили:" "Over the past month, how much were you bothered by:" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_1" "5" "Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?" "Recurring distressing and unwanted memories of the stressful event?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "«Тяжёлые» менее однозначно передаёт эмоциональное беспокойство, чем disturbing." "" "" "" "pending_independent_review"
"pcl5" "pcl5_2" "6" "Повторяющиеся тяжёлые сны о стрессовом событии?" "Recurring distressing dreams about the stressful event?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "Та же неоднозначность «тяжёлые» применительно к снам." "" "" "" "pending_independent_review"
"pcl5" "pcl5_3" "6" "Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?" "Suddenly feeling or acting as though the stressful event were happening again (as though you were actually back there, experiencing it all over again)?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_4" "6" "Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?" "Becoming very upset when something reminded you of the stressful event?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_5" "6" "Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?" "Strong bodily reactions when something reminded you of the stressful event (for example, a racing heart, difficulty breathing, sweating)?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_6" "6" "Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?" "Avoiding memories, thoughts, or feelings associated with the stressful event?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_7" "6" "Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?" "Avoiding external reminders of the stressful event (for example, people, places, conversations, activities, objects, or situations)?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_8" "6" "Трудности с тем, чтобы вспомнить важные части стрессового события?" "Difficulty remembering important parts of the stressful event?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_9" "6" "Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?" "Strong negative beliefs about yourself, other people, or the world (for example, thoughts such as “I am bad,” “there is something seriously wrong with me,” “no one can be trusted,” “the world is completely dangerous”)?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_10" "6" "Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?" "Blaming yourself or someone else for the stressful event or for what happened afterward?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_11" "6" "Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?" "Strong negative feelings such as fear, horror, anger, guilt, or shame?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_12" "6" "Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?" "Losing interest in activities that used to give you pleasure?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_13" "6" "Ощущение отдалённости или оторванности от других людей?" "Feeling distant or cut off from other people?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_14" "7" "Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?" "Difficulty experiencing positive feelings (for example, being unable to feel joy or love for people close to you)?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_15" "7" "Раздражительность, вспышки гнева или агрессивное поведение?" "Irritability, outbursts of anger, or aggressive behavior?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "priority_review" "Irritable behavior описывает поведение; «раздражительность» может включать внутреннее состояние." "" "" "" "pending_independent_review"
"pcl5" "pcl5_16" "7" "Чрезмерный риск или поступки, которые могут причинить Вам вред?" "Excessive risk-taking or actions that could harm you?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_17" "7" "Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?" "Increased vigilance, wariness, or a feeling that you need to be on guard all the time?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "Добавлено «всё время»; это может усилить требование постоянства настороженности." "" "" "" "pending_independent_review"
"pcl5" "pcl5_18" "7" "Взвинченность или склонность легко вздрагивать?" "Feeling keyed up or being easily startled?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "«Взвинченность» шире jumpy и может пониматься как общее возбуждение." "" "" "" "pending_independent_review"
"pcl5" "pcl5_19" "7" "Трудности с концентрацией внимания?" "Difficulty concentrating?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_20" "7" "Трудности с засыпанием или прерывистый сон?" "Difficulty falling asleep or interrupted sleep?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "«Прерывистый сон» описывает результат, staying asleep — способность сохранять сон." "" "" "" "pending_independent_review"
"pcl5" "option_0" "5" "Совсем нет" "Not at all" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "option_1" "5" "Немного" "A little" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "option_2" "5" "Умеренно" "Moderately" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "option_3" "5" "Сильно" "Very much" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "priority_review" "«Сильно» вернулось как Very much; оригинальное Quite a bit может задавать иной уровень интенсивности." "" "" "" "pending_independent_review"
"pcl5" "option_4" "5" "Крайне сильно" "Extremely" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
1 scale item_id source_page russian_text backtranslation english_original_reference review_flag review_note agreed_russian_text clinician decision_date status
2 trauma_filter introduction 3 Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства. Sometimes people experience events that are exceptionally frightening, horrifying, or traumatic. For example: a serious accident or fire; physical or sexual assault or violence; an earthquake or flood; war; witnessing someone being killed or seriously wounded; a loved one dying as a result of murder or suicide. not_compared Переведено по PDF; с английским источником фильтра в этом проходе не сверялось. pending_independent_review
3 trauma_filter trauma_filter 3 Случалось ли с Вами подобное событие? Has an event like this happened to you? not_compared Переведено по PDF; с английским источником фильтра в этом проходе не сверялось. pending_independent_review
4 trauma_filter option_Да 3 Да Yes not_compared Переведено по PDF; с английским источником фильтра в этом проходе не сверялось. pending_independent_review
5 trauma_filter option_Нет 3 Нет No not_compared Переведено по PDF; с английским источником фильтра в этом проходе не сверялось. pending_independent_review
6 pcl5 instruction 5 Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц. Listed below are problems that people sometimes experience after a very difficult, stressful event. Keeping in mind the event that was most difficult for you, read each item carefully and choose the answer that shows how much this problem bothered you over the past month. https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) review Смысл инструкции и месячный период сохранены; «тяжёлый» допускает разные оттенки. pending_independent_review
7 pcl5 optional_event 5 Ваше самое тяжёлое событие: ______ The event that was most difficult for you: ______ https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
8 pcl5 stem 5 Насколько сильно за последний месяц Вас беспокоили: Over the past month, how much were you bothered by: https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
9 pcl5 pcl5_1 5 Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии? Recurring distressing and unwanted memories of the stressful event? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) review «Тяжёлые» менее однозначно передаёт эмоциональное беспокойство, чем disturbing. pending_independent_review
10 pcl5 pcl5_2 6 Повторяющиеся тяжёлые сны о стрессовом событии? Recurring distressing dreams about the stressful event? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) review Та же неоднозначность «тяжёлые» применительно к снам. pending_independent_review
11 pcl5 pcl5_3 6 Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)? Suddenly feeling or acting as though the stressful event were happening again (as though you were actually back there, experiencing it all over again)? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
12 pcl5 pcl5_4 6 Сильное расстройство, когда что-то напоминало Вам о стрессовом событии? Becoming very upset when something reminded you of the stressful event? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
13 pcl5 pcl5_5 6 Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)? Strong bodily reactions when something reminded you of the stressful event (for example, a racing heart, difficulty breathing, sweating)? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
14 pcl5 pcl5_6 6 Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием? Avoiding memories, thoughts, or feelings associated with the stressful event? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
15 pcl5 pcl5_7 6 Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)? Avoiding external reminders of the stressful event (for example, people, places, conversations, activities, objects, or situations)? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
16 pcl5 pcl5_8 6 Трудности с тем, чтобы вспомнить важные части стрессового события? Difficulty remembering important parts of the stressful event? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
17 pcl5 pcl5_9 6 Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)? Strong negative beliefs about yourself, other people, or the world (for example, thoughts such as “I am bad,” “there is something seriously wrong with me,” “no one can be trusted,” “the world is completely dangerous”)? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
18 pcl5 pcl5_10 6 Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него? Blaming yourself or someone else for the stressful event or for what happened afterward? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
19 pcl5 pcl5_11 6 Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд? Strong negative feelings such as fear, horror, anger, guilt, or shame? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
20 pcl5 pcl5_12 6 Потеря интереса к занятиям, которые раньше приносили Вам удовольствие? Losing interest in activities that used to give you pleasure? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
21 pcl5 pcl5_13 6 Ощущение отдалённости или оторванности от других людей? Feeling distant or cut off from other people? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
22 pcl5 pcl5_14 7 Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)? Difficulty experiencing positive feelings (for example, being unable to feel joy or love for people close to you)? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
23 pcl5 pcl5_15 7 Раздражительность, вспышки гнева или агрессивное поведение? Irritability, outbursts of anger, or aggressive behavior? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) priority_review Irritable behavior описывает поведение; «раздражительность» может включать внутреннее состояние. pending_independent_review
24 pcl5 pcl5_16 7 Чрезмерный риск или поступки, которые могут причинить Вам вред? Excessive risk-taking or actions that could harm you? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
25 pcl5 pcl5_17 7 Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку? Increased vigilance, wariness, or a feeling that you need to be on guard all the time? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) review Добавлено «всё время»; это может усилить требование постоянства настороженности. pending_independent_review
26 pcl5 pcl5_18 7 Взвинченность или склонность легко вздрагивать? Feeling keyed up or being easily startled? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) review «Взвинченность» шире jumpy и может пониматься как общее возбуждение. pending_independent_review
27 pcl5 pcl5_19 7 Трудности с концентрацией внимания? Difficulty concentrating? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
28 pcl5 pcl5_20 7 Трудности с засыпанием или прерывистый сон? Difficulty falling asleep or interrupted sleep? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) review «Прерывистый сон» описывает результат, staying asleep — способность сохранять сон. pending_independent_review
29 pcl5 option_0 5 Совсем нет Not at all https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
30 pcl5 option_1 5 Немного A little https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
31 pcl5 option_2 5 Умеренно Moderately https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
32 pcl5 option_3 5 Сильно Very much https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) priority_review «Сильно» вернулось как Very much; оригинальное Quite a bit может задавать иной уровень интенсивности. pending_independent_review
33 pcl5 option_4 5 Крайне сильно Extremely https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
@@ -0,0 +1,39 @@
"scale" "item_id" "source_page" "russian_text" "english_original_reference" "independent_backtranslation" "concept_difference" "severity" "agreed_russian_text" "clinician" "decision_date" "status"
"pcptsd5" "introduction" "3" "Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства." "" "" "" "" "" "" "" "pending"
"pcptsd5" "trauma_filter" "3" "Случалось ли с Вами подобное событие?" "" "" "" "" "" "" "" "pending"
"pcptsd5" "instruction" "3" "За последний месяц Вы…" "" "" "" "" "" "" "" "pending"
"pcptsd5" "pcptsd5_1" "4" "…видели кошмары об этом событии (событиях) или думали о нём, когда не хотели этого?" "" "" "" "" "" "" "" "pending"
"pcptsd5" "pcptsd5_2" "4" "…изо всех сил старались не думать о событии (событиях) или специально избегали ситуаций, которые о нём напоминали?" "" "" "" "" "" "" "" "pending"
"pcptsd5" "pcptsd5_3" "4" "…были постоянно настороже, начеку или легко вздрагивали?" "" "" "" "" "" "" "" "pending"
"pcptsd5" "pcptsd5_4" "4" "…чувствовали оцепенение или отстранённость от людей, занятий или того, что Вас окружает?" "" "" "" "" "" "" "" "pending"
"pcptsd5" "pcptsd5_5" "4" "…чувствовали вину или не могли перестать обвинять себя или других в событии (событиях) или в проблемах, которые оно могло вызвать?" "" "" "" "" "" "" "" "pending"
"pcptsd5" "option_Да" "3–4" "Да" "" "" "" "" "" "" "" "pending"
"pcptsd5" "option_Нет" "3–4" "Нет" "" "" "" "" "" "" "" "pending"
"pcl5" "instruction" "5" "Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц." "" "" "" "" "" "" "" "pending"
"pcl5" "optional_event" "5" "Ваше самое тяжёлое событие: ______" "" "" "" "" "" "" "" "pending"
"pcl5" "stem" "5" "Насколько сильно за последний месяц Вас беспокоили:" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_1" "5" "Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_2" "5" "Повторяющиеся тяжёлые сны о стрессовом событии?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_3" "5" "Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_4" "5" "Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_5" "5" "Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_6" "5" "Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_7" "6" "Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_8" "6" "Трудности с тем, чтобы вспомнить важные части стрессового события?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_9" "6" "Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_10" "6" "Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_11" "6" "Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_12" "6" "Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_13" "6" "Ощущение отдалённости или оторванности от других людей?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_14" "6" "Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_15" "6" "Раздражительность, вспышки гнева или агрессивное поведение?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_16" "6" "Чрезмерный риск или поступки, которые могут причинить Вам вред?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_17" "6" "Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_18" "6" "Взвинченность или склонность легко вздрагивать?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_19" "6" "Трудности с концентрацией внимания?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_20" "7" "Трудности с засыпанием или прерывистый сон?" "" "" "" "" "" "" "" "pending"
"pcl5" "option_0" "5" "Совсем нет" "" "" "" "" "" "" "" "pending"
"pcl5" "option_1" "5" "Немного" "" "" "" "" "" "" "" "pending"
"pcl5" "option_2" "5" "Умеренно" "" "" "" "" "" "" "" "pending"
"pcl5" "option_3" "5" "Сильно" "" "" "" "" "" "" "" "pending"
"pcl5" "option_4" "5" "Крайне сильно" "" "" "" "" "" "" "" "pending"
1 scale item_id source_page russian_text english_original_reference independent_backtranslation concept_difference severity agreed_russian_text clinician decision_date status
2 pcptsd5 introduction 3 Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства. pending
3 pcptsd5 trauma_filter 3 Случалось ли с Вами подобное событие? pending
4 pcptsd5 instruction 3 За последний месяц Вы… pending
5 pcptsd5 pcptsd5_1 4 …видели кошмары об этом событии (событиях) или думали о нём, когда не хотели этого? pending
6 pcptsd5 pcptsd5_2 4 …изо всех сил старались не думать о событии (событиях) или специально избегали ситуаций, которые о нём напоминали? pending
7 pcptsd5 pcptsd5_3 4 …были постоянно настороже, начеку или легко вздрагивали? pending
8 pcptsd5 pcptsd5_4 4 …чувствовали оцепенение или отстранённость от людей, занятий или того, что Вас окружает? pending
9 pcptsd5 pcptsd5_5 4 …чувствовали вину или не могли перестать обвинять себя или других в событии (событиях) или в проблемах, которые оно могло вызвать? pending
10 pcptsd5 option_Да 3–4 Да pending
11 pcptsd5 option_Нет 3–4 Нет pending
12 pcl5 instruction 5 Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц. pending
13 pcl5 optional_event 5 Ваше самое тяжёлое событие: ______ pending
14 pcl5 stem 5 Насколько сильно за последний месяц Вас беспокоили: pending
15 pcl5 pcl5_1 5 Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии? pending
16 pcl5 pcl5_2 5 Повторяющиеся тяжёлые сны о стрессовом событии? pending
17 pcl5 pcl5_3 5 Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)? pending
18 pcl5 pcl5_4 5 Сильное расстройство, когда что-то напоминало Вам о стрессовом событии? pending
19 pcl5 pcl5_5 5 Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)? pending
20 pcl5 pcl5_6 5 Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием? pending
21 pcl5 pcl5_7 6 Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)? pending
22 pcl5 pcl5_8 6 Трудности с тем, чтобы вспомнить важные части стрессового события? pending
23 pcl5 pcl5_9 6 Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)? pending
24 pcl5 pcl5_10 6 Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него? pending
25 pcl5 pcl5_11 6 Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд? pending
26 pcl5 pcl5_12 6 Потеря интереса к занятиям, которые раньше приносили Вам удовольствие? pending
27 pcl5 pcl5_13 6 Ощущение отдалённости или оторванности от других людей? pending
28 pcl5 pcl5_14 6 Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)? pending
29 pcl5 pcl5_15 6 Раздражительность, вспышки гнева или агрессивное поведение? pending
30 pcl5 pcl5_16 6 Чрезмерный риск или поступки, которые могут причинить Вам вред? pending
31 pcl5 pcl5_17 6 Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку? pending
32 pcl5 pcl5_18 6 Взвинченность или склонность легко вздрагивать? pending
33 pcl5 pcl5_19 6 Трудности с концентрацией внимания? pending
34 pcl5 pcl5_20 7 Трудности с засыпанием или прерывистый сон? pending
35 pcl5 option_0 5 Совсем нет pending
36 pcl5 option_1 5 Немного pending
37 pcl5 option_2 5 Умеренно pending
38 pcl5 option_3 5 Сильно pending
39 pcl5 option_4 5 Крайне сильно pending
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
{
"source_url": "https://emdr.ru/sites/default/files/2024-11/%D0%9A%D0%A0753%20%282%29.pdf",
"publisher": "Российское общество психиатров",
"guideline": "753, 2023",
"pdf": "tsq-guideline-2023.pdf",
"sha256": "d1148603af1114aa588ef206dc1cb00613acd9f19f150983d24bbcc9d50b3a5a",
"pages": [
129,
130
],
"current_edition_verified": false,
"rop_download_6016": "Contains a draft with year 202_; not treated as approved current edition."
}
+13 -3
View File
@@ -1,14 +1,24 @@
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.db import session
from app.db.bootstrap import init_db
from app.dialog.contracts import Event, get_engine from app.dialog.contracts import Event, get_engine
from app.main import app from app.main import app
from app.services.contracts import get_monitoring from app.services.contracts import get_monitoring
MEM = "sqlite+aiosqlite:///:memory:"
async def test_stubs_are_usable() -> None: async def test_stubs_are_usable() -> None:
replies = await get_engine().handle("tok", Event(type="text", text="привет")) await session.configure(identity_url=MEM, clinical_url=MEM)
assert replies and replies[0].text await init_db()
assert await get_monitoring().after_data_change("tok") == [] try:
replies = await get_engine().handle("tok", Event(type="text", text="привет"))
assert replies and replies[0].text
assert await get_monitoring().after_data_change("tok") == [] # unknown patient → no actions
assert await get_monitoring().is_in_crisis("tok") is False
finally:
await session.configure()
def test_api_fixture_routes() -> None: def test_api_fixture_routes() -> None:
+58
View File
@@ -0,0 +1,58 @@
"""Protect the reviewed PDF transcription and its connection to Recent scoring."""
import hashlib
import json
from itertools import product
from pathlib import Path
from app.core.cssrs_content import load_cssrs_russian
from app.core.questionnaire import score_cssrs_recent
CLINICAL = Path(__file__).resolve().parents[1] / "docs" / "clinical"
def test_source_hashes_and_complete_transcription():
form = load_cssrs_russian()
manifest = json.loads((CLINICAL / "review-status.json").read_text())["cssrs_russian_pdf"]
assert hashlib.sha256((CLINICAL / manifest["file"]).read_bytes()).hexdigest() == manifest["sha256"]
source = (CLINICAL / manifest["source_text"]).read_bytes()
assert hashlib.sha256(source).hexdigest() == manifest["source_text_sha256"]
assert form.version == manifest["content_version"]
# Reconstruct the complete extracted page, including numbering, guidance,
# original typos, answer order and the color legend. No wording is dropped.
blocks = [form.title, form.subtitle, form.period_label_original, form.initial_instruction]
blocks.extend(option.label for option in form.options)
for index, item in enumerate(form.items):
blocks.append(f"{index + 1}. {item.text}" if index < 6 else item.text)
if item.interviewer_guidance:
blocks.append(item.interviewer_guidance)
if item.id == "q2":
blocks.append(form.branch_instruction)
blocks.extend(["Low Risk", "Moderate Risk", "High Risk"])
assert " ".join(blocks) == source.decode().strip()
assert [item.recall_period for item in form.items] == ["past_month"] * 5 + ["lifetime", "past_3_months"]
def test_all_source_branches_and_color_categories_match_scorer():
form = load_cssrs_russian()
colors = {item.id: item.yes_risk for item in form.items}
assert list(colors.values()) == ["low", "low", "moderate", "high", "high", "moderate", "high"]
levels = ["none", "low", "moderate", "high"]
for values in product((0, 1), repeat=7):
all_answers = dict(zip(colors, values, strict=True))
asked = ["q1", "q2"]
if all_answers["q2"]:
asked.extend(["q3", "q4", "q5"])
asked.append("q6_lifetime")
if all_answers["q6_lifetime"]:
asked.append("q6_recent")
answers = {}
for item_id in asked:
partial = score_cssrs_recent(answers)
assert not partial.complete
assert partial.missing[0] == item_id
answers[item_id] = all_answers[item_id]
result = score_cssrs_recent(answers)
expected = max((levels.index(colors[key]) for key, value in answers.items() if value), default=0)
assert result.complete and result.risk == levels[expected]
assert result.required == tuple(asked)
+4
View File
@@ -67,6 +67,10 @@ async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None:
"safety_plans", "safety_plans",
"clock_state", "clock_state",
"audit_log", "audit_log",
"questionnaire_records",
"questionnaire_notices",
"questionnaire_tasks",
"scheduler_events",
} == clinical_tables } == clinical_tables
await init_db() # idempotent await init_db() # idempotent
+485
View File
@@ -0,0 +1,485 @@
"""Dialog engine (#10): consent, age gate, crisis without LLM, C-SSRS on POSSIBLE, scale sessions, revocation.
Two in-memory SQLite databases (identity / clinical), offline mock LLM wrapped in a spy that records every task
call, and a fake monitoring service (lane B stub) that records calls and returns scripted actions.
"""
from collections.abc import AsyncIterator
from typing import Any
import pytest
from sqlalchemy import select
from app.config import get_settings
from app.core import clock
from app.core.rules import Action
from app.core.safety import crisis_reply
from app.core.types import AlertLevel, ScrubbedText
from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.db.clinical import ItemResponse, Message, SafetyPlan
from app.dialog.contracts import Event, Reply, get_engine
from app.dialog.engine import Engine
from app.dialog.fsm import Ctx, Step, on_enter
from app.dialog.texts import load_dialog_texts
from app.integrations.base import VerifiedPerson, integrations
from app.llm.gateway import LLMGateway
from app.llm.mock import MockTasks
from app.llm.schemas import RiskVerdict
TOKEN = "a" * 32
TEXTS = load_dialog_texts()
MEM = "sqlite+aiosqlite:///:memory:"
class SpyTasks(MockTasks):
"""Offline mock that records which LLM tasks were called (to prove the crisis path never calls the LLM)."""
def __init__(self, risk: str | None = None) -> None:
self.calls: list[str] = []
self._risk = risk
async def extract_item(self, scale_item_id: str, item_text: str, max_score: int, answer: ScrubbedText) -> Any:
self.calls.append("extract_item")
return await super().extract_item(scale_item_id, item_text, max_score, answer)
async def extract_checkin(self, text: ScrubbedText) -> Any:
self.calls.append("extract_checkin")
return await super().extract_checkin(text)
async def risk_classify(self, text: ScrubbedText) -> RiskVerdict | None:
self.calls.append("risk_classify")
if self._risk is not None:
return RiskVerdict(level=self._risk, confidence=0.9) # type: ignore[arg-type]
return await super().risk_classify(text)
async def phrase(self, intent: str, payload: ScrubbedText, base_text: str) -> str | None:
self.calls.append("phrase")
return base_text
class FakeMonitoring:
def __init__(self, actions: list[Action] | None = None, in_crisis: bool = True) -> None:
self.calls = 0
self.actions = actions or []
self.in_crisis = in_crisis
async def after_data_change(self, token: str) -> list[Action]:
self.calls += 1
out, self.actions = self.actions, []
return out
async def is_in_crisis(self, token: str) -> bool:
return self.in_crisis
@pytest.fixture
async def dbs() -> AsyncIterator[None]:
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with session.get_identity_session() as s:
await identity_repo.create_patient(
s,
token=TOKEN,
display_name="Тестовый Пациент",
snils="000-000-000 00",
clinic_id="c1",
doctor_id="doc-test",
now=clock.now(),
birth_year=1992,
sex="m",
)
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-test", modules=[], sex="m")
yield
await session.configure() # back to default URLs for other test modules
@pytest.fixture
def spy() -> SpyTasks:
return SpyTasks()
@pytest.fixture
def mon() -> FakeMonitoring:
return FakeMonitoring()
@pytest.fixture
def engine(spy: SpyTasks, mon: FakeMonitoring) -> Engine:
return Engine(gateway=LLMGateway(spy), monitoring=mon)
# ----------------------------------------------------------------------------------------------- helpers
async def text(e: Engine, t: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="text", text=t))
async def press(e: Engine, data: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="button", button=data))
async def system(e: Engine, kind: str, **params: Any) -> list[Reply]:
return await e.handle(TOKEN, Event(type="system", system=kind, params=params)) # type: ignore[arg-type]
async def set_state(state: str, **data: Any) -> None:
async with session.get_clinical_session() as s:
await repo.set_dialog_state(s, token=TOKEN, state=state, data={"consent": True, **data}, now=clock.now())
async def get_state() -> tuple[str, dict[str, Any]]:
async with session.get_clinical_session() as s:
row = await repo.get_dialog_state(s, TOKEN)
assert row is not None
return row.state, dict(row.data)
async def stored_messages() -> list[Message]:
async with session.get_clinical_session() as s:
return list((await s.scalars(select(Message).where(Message.token == TOKEN).order_by(Message.id))).all())
async def identity_patient() -> Any:
async with session.get_identity_session() as s:
return await identity_repo.get_patient(s, TOKEN)
async def set_birth_year(year: int | None) -> None:
async with session.get_identity_session() as s:
patient = await identity_repo.get_patient(s, TOKEN)
assert patient is not None
patient.birth_year = year
def buttons(reply: Reply) -> list[str]:
return [b.data for row in reply.buttons for b in row]
# A fake module onboarding entry point, to check the hand-over without depending on #36.
@on_enter("zztest.onboarding.start")
async def _zz_onboarding(ctx: Ctx) -> Step:
return Step(replies=[Reply(text="zz onboarding")])
# ----------------------------------------------------------------------------------------------- registration
async def test_get_engine_returns_real_engine() -> None:
assert isinstance(get_engine(), Engine)
async def test_consent_accept_goes_to_idle(dbs: None, engine: Engine) -> None:
r = await engine.handle(TOKEN, Event(type="start"))
assert "152-ФЗ" in r[0].text and buttons(r[0]) == ["consent:yes", "consent:no"]
assert (await get_state())[0] == "consent"
r = await press(engine, "consent:yes")
state, data = await get_state()
# Birth year 1992 is known from registration → adult without asking; no module flow → idle.
assert state == "idle" and data["consent"] is True
assert r[-1].text.startswith(TEXTS.idle.welcome[:20]) and "consent:revoke" in buttons(r[-1])
patient = await identity_patient()
assert patient.consent_at is not None and patient.consent_ver == TEXTS.consent.version
async def test_connect_code_verified(dbs: None, engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None:
class Ident:
async def verify(self, auth_code: str) -> VerifiedPerson | None:
return VerifiedPerson(token=TOKEN, doctor_id="doc-test") if auth_code == "GOOD" else None
monkeypatch.setattr(integrations, "identity", Ident())
r = await engine.handle(TOKEN, Event(type="start", params={"code": "BAD"}))
assert r[0].text == TEXTS.consent.code_invalid and (await get_state())[0] == "new"
r = await engine.handle(TOKEN, Event(type="start", params={"code": "GOOD"}))
assert buttons(r[0]) == ["consent:yes", "consent:no"]
async def test_consent_refused_no_processing(dbs: None, engine: Engine, spy: SpyTasks) -> None:
await engine.handle(TOKEN, Event(type="start"))
r = await press(engine, "consent:no")
assert r[0].text == TEXTS.consent.refused and (await get_state())[0] == "refused"
r = await text(engine, "мне плохо, не сплю, позвоните 89161234567")
assert r[0].text == TEXTS.consent.refused
assert spy.calls == [] # nothing reached the LLM
assert await stored_messages() == [] # nothing stored without consent
assert (await identity_patient()).consent_at is None
r = await press(engine, "consent:restart")
assert buttons(r[0]) == ["consent:yes", "consent:no"]
async def test_minor_gets_child_helpline_and_stop(dbs: None, engine: Engine, spy: SpyTasks) -> None:
await set_birth_year(None)
await engine.handle(TOKEN, Event(type="start"))
r = await press(engine, "consent:yes")
assert buttons(r[-1]) == ["age:adult", "age:minor"]
r = await text(engine, "2012")
assert "8-800-2000-122" in r[0].text
state, data = await get_state()
assert state == "minor" and data["consent"] is False
assert (await identity_patient()).revoked_at is not None # a minor's consent is withdrawn
calls = len(spy.calls)
r = await text(engine, "а что дальше?")
assert "8-800-2000-122" in r[0].text and len(spy.calls) == calls
async def test_age_gate_button_adult(dbs: None, engine: Engine) -> None:
await set_birth_year(None)
await engine.handle(TOKEN, Event(type="start"))
await press(engine, "consent:yes")
r = await text(engine, "не скажу")
assert r[0].text.startswith(TEXTS.age_gate.unclear)
await press(engine, "age:adult")
assert (await get_state())[0] == "idle"
async def test_hand_over_to_module_onboarding(dbs: None, engine: Engine) -> None:
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, modules=["zztest"])
await engine.handle(TOKEN, Event(type="start"))
r = await press(engine, "consent:yes")
assert r[-1].text == "zz onboarding"
assert (await get_state())[0] == "zztest.onboarding.start"
# ----------------------------------------------------------------------------------------------- safety
async def test_acute_sends_script_without_any_llm_call(
dbs: None, engine: Engine, spy: SpyTasks, mon: FakeMonitoring
) -> None:
await set_state("idle")
r = await text(engine, "хочу покончить с собой")
assert len(r) == 1 and r[0].kind == "crisis"
assert r[0].text == crisis_reply(["suicide"]).text # verbatim fixed script
assert spy.calls == [] # lexicon ACUTE → risk_classify is not even called
state, data = await get_state()
assert state == "crisis" and data["crisis"]["open"] is True
event = data["safety_events"][-1]
assert event["level"] == "acute" and event["categories"] == ["suicide"] and event["handled"] == "crisis_script"
assert mon.calls == 1 # lane B gets the chance to escalate
async def test_repeat_crisis_does_not_duplicate_script(dbs: None, engine: Engine, spy: SpyTasks) -> None:
await set_state("idle")
script = (await text(engine, "хочу покончить с собой"))[0].text
r = await text(engine, "не хочу жить")
assert r[0].kind == "crisis" and r[0].text != script
assert r[0].text.startswith(TEXTS.crisis.repeat) and "112" in r[0].text
assert spy.calls == []
r = await text(engine, "ладно")
assert r[0].text == TEXTS.crisis.hold
_, data = await get_state()
assert [e["handled"] for e in data["safety_events"]] == ["crisis_script", "crisis_repeat"]
async def test_doctor_ack_leaves_crisis(dbs: None, engine: Engine) -> None:
await set_state("idle")
await text(engine, "хочу покончить с собой")
r = await system(engine, "doctor_ack")
assert r[0].text == TEXTS.crisis.closed
state, data = await get_state()
assert state == "idle" and data["crisis"]["open"] is False
# A new acute message after the crisis was closed gets the full script again.
r = await text(engine, "хочу покончить с собой")
assert r[0].text == crisis_reply(["suicide"]).text
async def test_crisis_left_when_monitoring_says_resolved(dbs: None, spy: SpyTasks) -> None:
mon = FakeMonitoring(in_crisis=False)
e = Engine(gateway=LLMGateway(spy), monitoring=mon)
await set_state("crisis", crisis={"open": True})
r = await text(e, "мне получше")
assert r[0].text == TEXTS.crisis.closed and (await get_state())[0] == "idle"
async def test_possible_starts_cssrs_and_returns(dbs: None, engine: Engine, spy: SpyTasks) -> None:
await set_state("idle")
r = await text(engine, "всё бессмысленно, устал от всего")
assert "risk_classify" in spy.calls
assert (await get_state())[0] == "scale.cssrs_screen"
assert r[-1].kind == "question" and [b.label for row in r[-1].buttons for b in row] == ["Нет", "Да"]
assert TEXTS.scale.risk_screen_intro in r[-1].text
r = await text(engine, "нет") # C-SSRS is buttons-only: free text is never sent to extract_item
assert r[0].text.startswith(TEXTS.scale.buttons_only) and "extract_item" not in spy.calls
for i in range(1, 7):
r = await press(engine, f"scale:cssrs_screen:cssrs_{i}:0")
state, data = await get_state()
assert state == "idle" and "cssrs_screen" not in data.get("scales", {})
assert r[0].text == TEXTS.scale.done
async with session.get_clinical_session() as s:
(res,) = await repo.list_scale_results(s, TOKEN, "cssrs_screen")
assert res.severity == "none" and res.scheduled is False and res.complete
assert data["safety_events"][-1]["handled"] == "risk_screen"
async def test_cssrs_high_goes_to_crisis(dbs: None, engine: Engine) -> None:
await set_state("idle")
await text(engine, "всё бессмысленно, устал от всего")
await press(engine, "scale:cssrs_screen:cssrs_1:1")
await press(engine, "scale:cssrs_screen:cssrs_2:1")
await press(engine, "scale:cssrs_screen:cssrs_3:0")
r = await press(engine, "scale:cssrs_screen:cssrs_4:1") # intent → high: the session ends at once
assert any(x.kind == "crisis" and x.text == crisis_reply([]).text for x in r)
assert (await get_state())[0] == "crisis"
async def test_llm_signal_can_raise_risk(dbs: None, mon: FakeMonitoring) -> None:
spy = SpyTasks(risk="possible")
e = Engine(gateway=LLMGateway(spy), monitoring=mon)
await set_state("idle")
await text(e, "обычный день") # lexicon NONE, LLM says possible → max() = POSSIBLE
state, data = await get_state()
assert state == "scale.cssrs_screen" and data["safety_events"][-1]["source"] == "llm"
# ----------------------------------------------------------------------------------------------- scale session
async def test_scale_session_buttons_free_text_and_clarify(dbs: None, engine: Engine, spy: SpyTasks) -> None:
await set_state("idle")
r = await system(engine, "scale_due", scale="phq9", scheduled=True)
assert (await get_state())[0] == "scale.phq9"
assert r[0].kind == "question" and len(buttons(r[0])) == 4 and "1/9" in r[0].text
await press(engine, "scale:phq9:phq9_1:1")
r = await text(engine, "почти каждый день") # mock extract: confident score 3
assert "3/9" in r[0].text
r = await text(engine, "ну такое") # no cue → low confidence → clarify, nothing stored
assert r[0].text.startswith(TEXTS.scale.clarify) and "3/9" in r[0].text
r = await press(engine, "scale:phq9:phq9_1:2") # stale button from an earlier question → re-ask
assert "3/9" in r[0].text
for i in range(3, 9):
await press(engine, f"scale:phq9:phq9_{i}:0")
r = await text(engine, "нет") # item 9 is buttons-only
assert r[0].text.startswith(TEXTS.scale.buttons_only)
assert spy.calls.count("extract_item") == 2
r = await press(engine, "scale:phq9:phq9_9:0")
assert r[0].text == TEXTS.scale.done and (await get_state())[0] == "idle"
async with session.get_clinical_session() as s:
(res,) = await repo.list_scale_results(s, TOKEN, "phq9")
items = (await s.scalars(select(ItemResponse).order_by(ItemResponse.id))).all()
assert (res.total, res.item9, res.complete, res.scheduled) == (4, 0, True, True)
assert [(i.item_id, i.source) for i in items[:2]] == [("phq9_1", "button"), ("phq9_2", "llm")]
assert items[1].score == 3 and items[1].confidence is not None
async def test_phq9_item9_positive_crisis_then_cssrs(dbs: None, engine: Engine, spy: SpyTasks) -> None:
await set_state("idle")
await system(engine, "scale_due", scale="phq9", scheduled=False)
for i in range(1, 9):
await press(engine, f"scale:phq9:phq9_{i}:0")
r = await press(engine, "scale:phq9:phq9_9:2")
kinds = [x.kind for x in r]
assert "crisis" in kinds and r[kinds.index("crisis")].text == crisis_reply([]).text
assert r[-1].kind == "question" and "cssrs" not in r[-1].text # C-SSRS q1 asked after the script
state, data = await get_state()
assert state == "scale.cssrs_screen" and data["crisis"]["open"] is True
assert data["scales"]["cssrs_screen"]["return_state"] == "crisis"
async with session.get_clinical_session() as s:
(res,) = await repo.list_scale_results(s, TOKEN, "phq9")
assert res.item9 == 2 and res.scheduled is False and res.flags == {"phq9_item9_positive": True}
assert "extract_item" not in spy.calls
# A repeated acute phrase during the C-SSRS: short text, no second script, the screen continues.
r = await text(engine, "не хочу жить")
assert r[0].text.startswith(TEXTS.crisis.repeat) and (await get_state())[0] == "scale.cssrs_screen"
# ----------------------------------------------------------------------------------------------- monitoring actions
async def test_monitoring_actions_executed(dbs: None, spy: SpyTasks) -> None:
mon = FakeMonitoring(
actions=[
Action(type="alert", level=AlertLevel.HIGH, code="x"),
Action(type="show_safety_plan", code="phq9_item9"),
Action(type="schedule_scale", code="mood_low_3d", params={"scale": "phq9", "scheduled": False}),
]
)
e = Engine(gateway=LLMGateway(spy), monitoring=mon)
async with session.get_clinical_session() as s:
s.add(SafetyPlan(token=TOKEN, steps=["Позвонить брату Олегу", "Прогулка"], updated_at=clock.now()))
await set_state("idle")
r = await press(e, "menu:contact_doctor") # records a contact request → monitoring runs
assert r[0].text == TEXTS.idle.contact_doctor_ack
plan = next(x for x in r if x.kind == "safety_plan")
assert "Позвонить брату Олегу" in plan.text
assert r[-1].kind == "question" # unscheduled PHQ-9 started right away (idle = resting)
state, data = await get_state()
assert state == "scale.phq9" and data["scales"]["phq9"]["scheduled"] is False
assert data["contact_requests"]
# Stored outbound texts are scrubbed (the plan names a relative).
assert not any("Олег" in m.text_scrubbed for m in await stored_messages())
async def test_suggest_visit_and_med_info_phrased(dbs: None, spy: SpyTasks) -> None:
mon = FakeMonitoring(
actions=[
Action(type="med_info", code="alcohol_3d", params={"topic": "alcohol_interaction"}),
Action(type="suggest_visit", code="alcohol_3d", params={"reason": "alcohol"}),
]
)
e = Engine(gateway=LLMGateway(spy), monitoring=mon)
await set_state("idle")
r = await press(e, "menu:contact_doctor")
assert any("алкогол" in x.text for x in r) and buttons(r[-1]) == ["menu:visit"]
# ------------------------------------------------------------------------------------ consent revocation, privacy
async def test_revoke_consent_then_silence(dbs: None, engine: Engine, spy: SpyTasks) -> None:
await set_state("idle")
r = await press(engine, "consent:revoke")
assert r[0].text == TEXTS.revoked.confirm
state, data = await get_state()
assert state == "consent_revoked" and data["consent"] is False and data["delete_requested"] is True
assert (await identity_patient()).revoked_at is not None
before = len(await stored_messages())
assert await text(engine, "привет, как дела?") == []
assert await system(engine, "daily_checkin") == []
assert spy.calls == [] and len(await stored_messages()) == before
r = await engine.handle(TOKEN, Event(type="start")) # coming back = asking for consent again
assert buttons(r[0]) == ["consent:yes", "consent:no"]
async def test_revoke_by_text_command(dbs: None, engine: Engine) -> None:
await set_state("idle")
r = await text(engine, "Отозвать согласие")
assert r[0].text == TEXTS.revoked.confirm and (await get_state())[0] == "consent_revoked"
async def test_messages_stored_scrubbed(dbs: None, engine: Engine) -> None:
await set_state("idle")
r = await text(engine, "Перезвоните мне на 8 916 123-45-67, сегодня обычный день")
assert r and buttons(r[0]) # neutral support reply with the idle menu
msgs = await stored_messages()
inbound = [m.text_scrubbed for m in msgs if m.direction == "in"]
assert inbound and "[PHONE]" in inbound[0]
assert not any("123-45-67" in m.text_scrubbed or "916" in m.text_scrubbed for m in msgs)
assert any(m.direction == "out" for m in msgs)
async def test_db_unavailable_still_answers_crisis(engine: Engine, tmp_path: Any) -> None:
await session.configure(identity_url=MEM, clinical_url=f"sqlite+aiosqlite:///{tmp_path}/missing/dir/x.db")
try:
r = await text(engine, "хочу покончить с собой")
assert r[0].kind == "crisis" and "112" in r[0].text
assert crisis_reply(["suicide"]).doctor_notified not in r[0].text # nobody could be notified
r = await text(engine, "привет")
assert r[0].text == TEXTS.unavailable
finally:
await session.configure()
def test_dialog_texts_avoid_forbidden_words() -> None:
raw = (get_settings().config_dir / "texts" / "dialog.yaml").read_text(encoding="utf-8").lower()
body = "\n".join(line for line in raw.splitlines() if not line.lstrip().startswith("#"))
for word in ("учёт", "учет", "контрол", "надзор", "диагноз:"):
assert word not in body
assert "152-фз" in body and "8-800-2000-122" in body
+385
View File
@@ -0,0 +1,385 @@
"""Engine gaps found by the 13-day run (#56): no same-day C-SSRS repeat, scale intro registry, prefix triggers
with a button payload (medication buttons carry the dose), abandoned-step timeout."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from typing import Any
import pytest
from sqlalchemy import select
from app.config import Settings
from app.core import clock
from app.core.rules import Action
from app.core.scales import load_scale
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Dose, ItemResponse, Prescription
from app.dialog import engine as engine_module
from app.dialog import fsm, scale_session
from app.dialog.contracts import Event, Reply
from app.dialog.engine import STEP_AT, Engine
from app.dialog.flows.meds import load_meds_texts
from app.dialog.flows.veteran_daily import load_daily_texts
from app.dialog.fsm import Ctx, Step, get_trigger, is_registered, trigger, trigger_payload
from app.dialog.texts import load_dialog_texts
from app.llm.gateway import LLMGateway
from app.llm.mock import MockTasks
TOKEN = "g" * 32
OTHER = "h" * 32
MEM = "sqlite+aiosqlite:///:memory:"
NOW = datetime(2026, 10, 7, 9, 0)
TEXTS = load_dialog_texts()
MEDS = load_meds_texts()
DAILY = load_daily_texts()
SCREEN = TEXTS.settings.risk_screen_scale
class ScriptedMonitoring:
def __init__(self) -> None:
self.actions: list[Action] = []
async def after_data_change(self, token: str) -> list[Action]:
out, self.actions = self.actions, []
return out
async def is_in_crisis(self, token: str) -> bool:
return True
@pytest.fixture
async def mon(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[ScriptedMonitoring]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
clock.set_now(NOW)
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with session.get_clinical_session() as s:
await repo.upsert_profile(
s, token=TOKEN, doctor_id="doc", modules=["veteran"], sex="m", birth_year=1990, therapy_start=NOW.date()
)
s.add(Prescription(token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}))
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True}, now=clock.now())
yield ScriptedMonitoring()
clock.reset()
await session.configure()
@pytest.fixture
def engine(mon: ScriptedMonitoring) -> Engine:
return Engine(gateway=LLMGateway(MockTasks()), monitoring=mon)
async def press(e: Engine, data: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="button", button=data))
async def text(e: Engine, t: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="text", text=t))
async def system(e: Engine, kind: str, **params: Any) -> list[Reply]:
return await e.handle(TOKEN, Event(type="system", system=kind, params=params)) # type: ignore[arg-type]
async def answer(e: Engine, scale_id: str, values: list[int]) -> list[Reply]:
out: list[Reply] = []
for item, value in zip(load_scale(scale_id).items, values, strict=False):
out += await press(e, f"scale:{scale_id}:{item.id}:{value}")
return out
def buttons(replies: list[Reply]) -> list[str]:
return [b.data for r in replies for row in r.buttons for b in row]
async def get_state() -> tuple[str, dict[str, Any]]:
async with session.get_clinical_session() as s:
row = await repo.get_dialog_state(s, TOKEN)
assert row is not None
return row.state, dict(row.data)
async def results(scale: str) -> list[Any]:
async with session.get_clinical_session() as s:
return list(await repo.list_scale_results(s, TOKEN, scale))
async def checkin() -> Any:
async with session.get_clinical_session() as s:
rows = await repo.list_checkins(s, TOKEN)
return rows[-1] if rows else None
async def doses() -> list[Dose]:
async with session.get_clinical_session() as s:
return list((await s.scalars(select(Dose).where(Dose.token == TOKEN).order_by(Dose.id))).all())
# ----------------------------------------------------------------------------------------------- 1. C-SSRS repeat
async def test_item9_right_after_cssrs_does_not_repeat_screen(engine: Engine, mon: ScriptedMonitoring) -> None:
await system(engine, "scale_due", scale=SCREEN, scheduled=False)
assert (await get_state())[0] == f"scale.{SCREEN}"
await answer(engine, SCREEN, [0] * 6)
clock.advance(hours=1)
await system(engine, "scale_due", scale="phq9", scheduled=False)
# The rule phq9_item9 also asks for the screen — neither path may repeat it within the cooldown.
mon.actions = [Action(type="schedule_scale", code="phq9_item9", params={"scale": SCREEN, "scheduled": False})]
r = await answer(engine, "phq9", [1] * 8 + [1])
assert any(x.kind == "crisis" for x in r) # the fixed crisis script still goes out
state, data = await get_state()
assert state == "crisis" and data["crisis"]["open"] is True and not data.get("pending_scales")
assert len(await results(SCREEN)) == 1
async def test_item9_after_old_cssrs_asks_screen_again(engine: Engine) -> None:
await system(engine, "scale_due", scale=SCREEN, scheduled=False)
await answer(engine, SCREEN, [0] * 6)
clock.advance(hours=TEXTS.settings.cssrs_cooldown_hours + 1)
await system(engine, "scale_due", scale="phq9", scheduled=False)
r = await answer(engine, "phq9", [1] * 8 + [1])
assert any(x.kind == "crisis" for x in r)
assert (await get_state())[0] == f"scale.{SCREEN}" and TEXTS.scale.risk_screen_intro in r[-1].text
async def test_unfinished_cssrs_does_not_count(engine: Engine) -> None:
await system(engine, "scale_due", scale=SCREEN, scheduled=False)
await answer(engine, SCREEN, [0, 0]) # left halfway: no complete result
await system(engine, "scale_due", scale="phq9", scheduled=False) # queued behind the screen
async with session.get_clinical_session() as s:
assert not await scale_session.recent_risk_screen(_ctx(s), SCREEN)
def _ctx(s: Any, state: str = "idle", data: dict[str, Any] | None = None) -> Ctx:
return Ctx(
token=TOKEN,
state=state,
data=data or {},
now=clock.now(),
session=s,
gateway=LLMGateway(MockTasks()),
monitoring=ScriptedMonitoring(),
texts=TEXTS,
)
# ----------------------------------------------------------------------------------------------- 2. intros
async def test_registered_intro_text_and_callable(engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None:
seen: list[tuple[str, bool, str]] = []
def intro(ctx: Ctx, scale_id: str, scheduled: bool, return_state: str) -> str | None:
seen.append((scale_id, scheduled, return_state))
return "Вступление к GAD-7"
monkeypatch.setattr(scale_session, "_intros", dict(scale_session._intros))
scale_session.register_scale_intro("gad7", intro)
with pytest.raises(ValueError):
scale_session.register_scale_intro("gad7", "второе")
r = await system(engine, "scale_due", scale="gad7", scheduled=True)
assert r[0].text.startswith("Вступление к GAD-7") and seen == [("gad7", True, "idle")]
scale_session.register_scale_intro("gad7", "Текстовое вступление", replace=True)
async with session.get_clinical_session() as s:
step = await scale_session.start_scale(_ctx(s), "gad7", return_state="idle")
assert step.data["scales"]["gad7"]["intro"] == "Текстовое вступление"
explicit = await scale_session.start_scale(_ctx(s), "gad7", return_state="idle", intro="Своё")
assert explicit.data["scales"]["gad7"]["intro"] == "Своё"
def test_daily_intros_use_registry_not_enter_hooks() -> None:
for scale_id in DAILY.settings.intro_scales:
assert not is_registered(f"scale.{scale_id}") # no per-scale enter hooks any more
assert scale_id in scale_session._intros
async def test_unscheduled_intro_from_registry(engine: Engine) -> None:
r = await system(engine, "scale_due", scale="phq9", scheduled=False)
assert DAILY.scale_intros.unscheduled["phq9"] in r[0].text
# ----------------------------------------------------------------------------------------------- 3. payload buttons
async def test_prefix_trigger_payload_and_precedence(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(fsm, "_triggers", dict(fsm._triggers))
monkeypatch.setattr(fsm, "_prefix_triggers", dict(fsm._prefix_triggers))
def handler(tag: str) -> Any:
async def fn(ctx: Ctx, event: Event) -> Step:
return Step(data={"tag": tag, "payload": trigger_payload(event)})
return fn
trigger("button:zz:a:*")(handler("short"))
trigger("button:zz:a:b:*")(handler("long"))
trigger("button:zz:a:exact")(handler("exact"))
with pytest.raises(ValueError):
trigger("button:zz:a:*")(handler("dup"))
async def run(key: str) -> dict[str, Any] | None:
fn = get_trigger(key)
if fn is None:
return None
return (await fn(None, Event(type="button", button=key.split(":", 1)[1]))).data # type: ignore[arg-type]
assert await run("button:zz:a:42") == {"tag": "short", "payload": "42"}
assert await run("button:zz:a:b:7:x") == {"tag": "long", "payload": "7:x"}
assert await run("button:zz:a:exact") == {"tag": "exact", "payload": None}
assert await run("button:zz:a:") is None and await run("button:zz:a") is None
async def test_reminder_buttons_carry_dose_and_no_state_key(engine: Engine) -> None:
async with session.get_clinical_session() as s:
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": True, "med_pending": {"dose_id": 1}}, now=clock.now()
)
r = await system(engine, "med_reminder")
[dose] = await doses()
assert buttons(r) == [f"med:taken:{dose.id}", f"med:skip:{dose.id}", f"med:side_effects:{dose.id}"]
assert "med_pending" not in (await get_state())[1]
skip = await press(engine, f"med:skip:{dose.id}")
assert f"med:skip_reason:side_effects:{dose.id}" in buttons(skip)
se = await press(engine, f"med:skip_reason:side_effects:{dose.id}")
await press(engine, f"med:se:nausea:{dose.id}")
[dose] = await doses()
assert dose.status == "skipped" and dose.side_effects == "nausea" and "med:se:nausea:" in buttons(se)[0]
async def test_two_reminders_each_button_answers_its_own_dose(engine: Engine) -> None:
first = await system(engine, "med_reminder")
second = await system(engine, "med_reminder")
await press(engine, buttons(first)[0]) # «Принял» of the FIRST reminder, pressed after the second arrived
d1, d2 = await doses()
assert (d1.status, d2.status) == ("taken", "pending")
await press(engine, buttons(second)[1])
assert [d.status for d in await doses()] == ["taken", "skipped"]
@pytest.mark.parametrize("data", ["med:taken", "med:skip", "med:se:nausea", "med:dose:yes", "med:skip_reason:quit"])
async def test_legacy_payloadless_buttons_are_stale(engine: Engine, data: str) -> None:
await system(engine, "med_reminder")
r = await press(engine, data)
assert r[0].text == MEDS.stale and await checkin() is None
assert [d.status for d in await doses()] == ["pending"]
async def test_foreign_unknown_and_old_dose_buttons_are_stale(engine: Engine) -> None:
async with session.get_clinical_session() as s:
rx = Prescription(token=OTHER, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]})
s.add(rx)
await s.flush()
foreign = Dose(token=OTHER, prescription_id=rx.id, planned_at=clock.now(), status="pending")
s.add(foreign)
await s.flush()
foreign_id = foreign.id
for data in (f"med:taken:{foreign_id}", "med:taken:9999", "med:taken:garbage", "med:taken:2026-10-06"):
assert (await press(engine, data))[0].text == MEDS.stale
old = await system(engine, "med_reminder")
clock.advance(days=1)
assert (await press(engine, buttons(old)[0]))[0].text == MEDS.stale
assert await checkin() is None
async def test_reminder_without_dose_row_uses_day_payload(engine: Engine) -> None:
async with session.get_clinical_session() as s:
for row in await s.scalars(select(Prescription)):
await s.delete(row)
r = await system(engine, "med_reminder")
assert buttons(r)[0] == f"med:taken:{NOW.date().isoformat()}"
r = await press(engine, buttons(r)[0])
assert r[0].text == MEDS.taken_ack and (await checkin()).med_taken is True and await doses() == []
async def test_checkin_med_step_answers_todays_dose(engine: Engine) -> None:
await system(engine, "med_reminder")
await system(engine, "daily_checkin")
await press(engine, "daily:mood:5")
await press(engine, "daily:med:yes")
assert [d.status for d in await doses()] == ["taken"]
# ----------------------------------------------------------------------------------------------- 4. abandon timeout
async def test_abandoned_checkin_returns_to_idle(engine: Engine) -> None:
await system(engine, "daily_checkin")
await press(engine, "daily:mood:3")
assert (await get_state())[0].startswith("daily.")
clock.advance(hours=TEXTS.settings.abandon_after_hours + 1)
r = await system(engine, "med_reminder")
state, data = await get_state()
assert state == "idle" and "checkin" not in data
assert len(r) == 1 and r[0].text.startswith(MEDS.reminder.text.split("{")[0]) # no check-in question repeated
assert (await checkin()).mood == 3 # stored answer survives
async def test_short_pause_keeps_checkin(engine: Engine) -> None:
await system(engine, "daily_checkin")
clock.advance(hours=TEXTS.settings.abandon_after_hours - 1)
r = await press(engine, "daily:mood:6")
assert (await get_state())[0] != "idle" and r
async def test_abandoned_scale_lets_queued_scale_start(engine: Engine) -> None:
await system(engine, "scale_due", scale="phq9", scheduled=True)
await answer(engine, "phq9", [1, 2])
clock.advance(hours=4)
await system(engine, "scale_due", scale="pcl5", scheduled=True) # queued; system events don't extend the step
clock.advance(hours=3)
r = await text(engine, "добрый вечер")
state, data = await get_state()
assert state == "scale.pcl5" and "phq9" not in data["scales"] and not data.get("pending_scales")
assert DAILY.scale_intros.scheduled["pcl5"] in r[-1].text
assert await results("phq9") == [] # unfinished: no total, no severity
async with session.get_clinical_session() as s:
stored = (await s.scalars(select(ItemResponse).where(ItemResponse.scale == "phq9"))).all()
assert [x.score for x in stored] == [1, 2]
async def test_timeout_never_applies_in_crisis(engine: Engine) -> None:
await system(engine, "scale_due", scale="phq9", scheduled=False)
await answer(engine, "phq9", [0] * 8 + [2]) # item 9 → crisis + C-SSRS (none done before)
state, data = await get_state()
assert state == f"scale.{SCREEN}" and data["crisis"]["open"] is True
clock.advance(hours=TEXTS.settings.abandon_after_hours * 3)
await press(engine, f"scale:{SCREEN}:cssrs_1:0")
state, data = await get_state()
assert state == f"scale.{SCREEN}" and data["scales"][SCREEN]["idx"] == 1
async def test_timeout_never_skips_onboarding_scale(engine: Engine) -> None:
item = load_scale("pcl5").items[0]
old = (clock.now() - timedelta(hours=48)).isoformat()
session_data = {
"idx": 0,
"answers": {},
"return_state": "veteran.onboarding.phq9",
"scheduled": True,
"intro": None,
"intro_shown": True,
}
async with session.get_clinical_session() as s:
await repo.set_dialog_state(
s,
token=TOKEN,
state="scale.pcl5",
data={"consent": True, "scales": {"pcl5": session_data}, STEP_AT: old},
now=clock.now(),
)
await press(engine, f"scale:pcl5:{item.id}:1")
state, data = await get_state()
assert state == "scale.pcl5" and data["scales"]["pcl5"]["idx"] == 1
async def test_timeout_disabled_same_day(engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None:
clock.set_now(NOW.replace(hour=1))
off = TEXTS.model_copy(update={"settings": TEXTS.settings.model_copy(update={"abandon_after_hours": 0})})
monkeypatch.setattr(engine_module, "load_dialog_texts", lambda: off)
await system(engine, "daily_checkin")
clock.advance(hours=20)
await press(engine, "daily:mood:4")
assert (await get_state())[0] != "idle" and (await checkin()).mood == 4
+407
View File
@@ -0,0 +1,407 @@
"""Synthetic released-config integration tests. Production approvals stay empty."""
import asyncio
import shutil
from datetime import UTC, datetime, timedelta
import pytest
import yaml
from sqlalchemy import select
from app.config import get_settings
from app.core import clock
from app.core.scales import clear_cache
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask
from app.dialog.contracts import Event
from app.dialog.engine import Engine
from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations
from app.llm.gateway import LLMGateway
from app.llm.mock import MockTasks
from app.scheduler import jobs
from app.services import questionnaire as service
TOKEN = "b" * 32
NOW = datetime(2026, 10, 3, 17, tzinfo=UTC)
@pytest.fixture
async def released(tmp_path, monkeypatch):
config = tmp_path / "config"
shutil.copytree(get_settings().config_dir, config)
for name in ("questionnaire", "intake_runtime"):
path = config / f"{name}.yaml"
data = yaml.safe_load(path.read_text())
approval = dict(
reviewer="Synthetic test",
decision_reference="fixture-only",
approved_at=NOW.isoformat(),
policy_version=data["version"],
)
data["approval"] = approval
if name == "questionnaire":
data.update(cssrs_positive_policy="graded", rus_audit_s_expand_at=1)
else:
data.update(enabled=True, content_review=approval, contact_review=approval)
path.write_text(yaml.safe_dump(data, allow_unicode=True))
monkeypatch.setattr(get_settings(), "config_dir", config)
monkeypatch.setattr(clock, "now", lambda: NOW)
monkeypatch.setattr(integrations, "notifier", MemoryNotifier())
monkeypatch.setattr(integrations, "outbox", MemoryOutbox())
clear_cache()
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
await init_db()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="synthetic-doctor", modules=["veteran"], sex="m")
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=NOW)
yield Engine(gateway=LLMGateway(MockTasks()))
await session.configure()
clear_cache()
async def rows(cls=QuestionnaireRecord):
async with session.get_clinical_session() as s:
return list((await s.scalars(select(cls))).all())
async def press(e, data):
return await e.handle(TOKEN, Event(type="button", button=data))
def answer_button(replies, choices=None):
choices = choices or {}
for reply in reversed(replies):
for line in reply.buttons:
for b in line:
if b.data.startswith("iq:"):
item = b.data.split(":")[2]
index = choices.get(item, 1 if item.startswith("cssrs_") else 0)
for line2 in reply.buttons:
for other in line2:
if other.data.endswith(":" + str(index)) and other.data.startswith("iq:"):
return other.data
return None
async def complete_intake(e, choices=None):
replies = await press(e, "intake:start")
for _ in range(100):
button = answer_button(replies, choices)
if button is None:
return replies
replies = await press(e, button)
raise AssertionError("intake did not terminate")
async def test_minimal_intake_raw_data_not_imputed_and_restart(released):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
(row,) = await rows()
assert row.status == "complete"
assert row.answers["trauma"] == 0 and not any(k.startswith("tsq_") for k in row.answers)
assert row.details["totals"]["tsq"]["total"] is None
assert row.details["totals"]["phq9"]["total"] is None
assert row.details["totals"]["rus_audit_s"]["total"] == 0
assert row.details["totals"]["cssrs_recent"]["risk"] == "none"
assert row.details["decision"]["primary_route"] == "self_help"
async def test_pause_restart_and_duplicate_callbacks(released):
replies = await press(released, "intake:start")
old = answer_button(replies)
await asyncio.gather(press(released, old), press(released, old))
(row,) = await rows()
assert row.answers == {"cssrs_q1": 0}
await press(released, "intake:pause")
restarted = Engine(gateway=LLMGateway(MockTasks()))
replies = await press(restarted, "intake:resume")
assert "cssrs_q2" in answer_button(replies)
await press(restarted, old)
assert (await rows())[0].answers == {"cssrs_q1": 0}
assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line)
@pytest.mark.parametrize("signal", ["help", "phq", "cssrs"])
async def test_immediate_stop_preserves_partial_answers(released, signal):
replies = await press(released, "intake:start")
if signal == "help":
replies = await press(released, "intake:help")
elif signal == "cssrs":
for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"):
replies = await press(released, answer_button(replies, {item: 0}))
else:
replies = await complete_existing(released, replies, {"trauma": 1, "phq9_1": 2, "phq9_9": 1})
assert any(r.kind == "crisis" for r in replies)
(row,) = await rows()
assert row.status == "interrupted" and row.completed_at is None
notices = await rows(QuestionnaireNotice)
assert len(notices) == 1 and notices[0].status == "test_sink"
assert "уведомлён" not in replies[0].text
async def complete_existing(e, replies, choices):
for _ in range(100):
button = answer_button(replies, choices)
if not button:
return replies
replies = await press(e, button)
raise AssertionError("not terminated")
async def test_full_tsq_expands_at_six_and_carries_alcohol_answers(released):
choices = {"tsq_" + str(i): 0 if i <= 6 else 1 for i in range(1, 11)}
choices.update(rus_audit_3=1, rus_audit_1=1, tbi=1, psychotropics=1)
await complete_intake(released, choices)
(row,) = await rows()
assert row.status == "complete"
assert row.details["totals"]["tsq"]["total"] == 6
assert row.details["totals"]["pcl5"]["total"] == 0
assert row.details["totals"]["rus_audit"]["complete"]
assert row.answers["rus_audit_3"] == 1
assert len([k for k in row.answers if k.startswith("rus_audit_")]) == 10
async def test_alcohol_conflict_keeps_positive_short_answer(released):
await complete_intake(released, {"trauma": 1, "rus_audit_3": 1, "rus_audit_1": 0})
(row,) = await rows()
assert row.status == "review_required" and row.answers["rus_audit_3"] == 1
assert row.details["stop_reason"] == "answer_conflict"
async def test_low_risk_shows_safety_plan_but_moderate_stops(released):
replies = await press(released, "intake:start")
replies = await press(released, answer_button(replies, {"cssrs_q1": 0}))
replies = await press(released, answer_button(replies))
replies = await press(released, answer_button(replies))
assert any(r.kind == "safety_plan" for r in replies)
assert "phq9_1" in answer_button(replies)
async def test_notification_failure_retry_and_consent_cancellation(released, monkeypatch):
class Failing:
async def send(self, *args, **kwargs):
raise OSError("sensitive error content must not be stored")
monkeypatch.setattr(integrations, "notifier", Failing())
await press(released, "intake:start")
await press(released, "intake:help")
(notice,) = await rows(QuestionnaireNotice)
assert notice.status == "pending" and notice.error_code == "OSError" and notice.attempts == 1
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(minutes=3))
notifier = MemoryNotifier()
monkeypatch.setattr(integrations, "notifier", notifier)
await service.dispatch_notices()
await service.dispatch_notices()
assert len(notifier.sent) == 1
assert (await rows(QuestionnaireNotice))[0].attempts == 2
await press(released, "consent:revoke")
async with session.get_clinical_session() as s:
state = await repo.get_dialog_state(s, TOKEN)
assert state.data["consent"] is False and "questionnaire_record" not in state.data
async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
class Failing:
async def push(self, *args):
raise OSError()
monkeypatch.setattr(integrations, "outbox", Failing())
await jobs.run_until_now()
tasks = await rows(QuestionnaireTask)
assert len(tasks) == 2
risk = next(t for t in tasks if t.kind == "cssrs_since_contact")
assert risk.delivery and risk.sent_at is None
outbox = MemoryOutbox()
monkeypatch.setattr(integrations, "outbox", outbox)
await jobs.run_until_now()
await jobs.run_until_now()
assert len(await rows(QuestionnaireTask)) == 2
assert len(outbox.drain(TOKEN)) == 1
assert len(await rows()) == 2
async def test_missing_since_contact_is_manual_review_not_recent(released):
async with session.get_clinical_session() as s:
task = await service.queue_task(s, TOKEN, "cssrs_since_contact", "synthetic", NOW)
task_id = task.id
replies = await released.handle(
TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id})
)
(row,) = await rows()
assert row.kind == "cssrs_since_contact" and row.status == "review_required" and not row.answers
assert row.details["stop_reason"] == "cssrs_since_contact_required"
assert not answer_button(replies)
async def test_disabled_or_no_adult_confirmation_cannot_start(released):
async with session.get_clinical_session() as s:
state = await repo.get_dialog_state(s, TOKEN)
state.data = {"consent": True}
await press(released, "intake:start")
assert await rows() == []
async def review_risk_record(e):
from app.api.questionnaire import ReviewIn, review
risk = next(r for r in reversed(await rows()) if r.kind == "cssrs_since_contact")
return await review(
TOKEN,
risk.id,
ReviewIn(
doctor_id="synthetic-doctor", assessment_performed=True, decision="resume", reference="synthetic/assessment"
),
)
async def test_doctor_review_is_audited_and_unblocks_diary(released):
from fastapi import HTTPException
from app.api.questionnaire import ReviewIn, history, review
from app.db.audit import AuditLog
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
await jobs.run_until_now()
risk = next(r for r in await rows() if r.kind == "cssrs_since_contact")
with pytest.raises(HTTPException) as exc:
await review(
TOKEN,
risk.id,
ReviewIn(doctor_id="other-doctor", assessment_performed=True, decision="resume", reference="test"),
)
assert exc.value.status_code == 404
await released.handle(TOKEN, Event(type="system", system="doctor_ack"))
async with session.get_clinical_session() as s:
assert (await repo.get_dialog_state(s, TOKEN)).data.get("intake_review_required")
await review_risk_record(released)
await jobs.run_until_now()
assert any(r.kind == "diary" and r.status == "active" for r in await rows())
await history(TOKEN, "synthetic-doctor")
audits = await rows(AuditLog)
assert {a.action for a in audits} >= {"view_questionnaires", "review_questionnaire"}
async def test_diary_numeric_skip_and_not_prescribed(released):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
await press(released, "menu:checkin")
await review_risk_record(released)
replies = await press(released, "menu:checkin")
for _ in range(30):
q = next(
(b.data.split(":")[2] for r in replies for line in r.buttons for b in line if b.data.startswith("iq:")),
None,
)
if q == "comment":
button = next(b.data for r in replies for line in r.buttons for b in line if b.data.endswith(":skip"))
replies = await press(released, button)
elif q:
replies = await press(released, answer_button(replies, {"medication": 3}))
elif any("часов" in r.text for r in replies):
invalid = await released.handle(TOKEN, Event(type="text", text="nan"))
assert any("число часов" in r.text for r in invalid)
replies = await released.handle(TOKEN, Event(type="text", text="6,5"))
else:
break
diary = next(r for r in await rows() if r.kind == "diary")
assert diary.status == "complete" and diary.answers["sleep_hours"] == 6.5
assert "medication_reason" not in diary.answers and "side_effects" not in diary.answers
assert diary.answers["comment"] is None
async def test_high_risk_diary_asks_safety_first_and_stops(released):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
async with session.get_clinical_session() as s:
(await repo.get_profile(s, TOKEN)).enhanced_monitoring = True
await press(released, "menu:checkin")
await review_risk_record(released)
replies = await press(released, "menu:checkin")
button = answer_button(replies)
assert "death_thoughts" in button
replies = await press(released, button)
assert any(r.kind == "crisis" for r in replies)
assert any(b.data == "safety_plan" for r in replies for line in r.buttons for b in line)
diary = next(r for r in await rows() if r.kind == "diary")
assert diary.answers == {"death_thoughts": 1} and diary.status == "interrupted"
async def test_unknown_sex_goes_to_review_without_a_zone(released):
async with session.get_clinical_session() as s:
(await repo.get_profile(s, TOKEN)).sex = None
await complete_intake(released, {"trauma": 1, "sex": 2})
(row,) = await rows()
assert row.status == "review_required" and row.answers["sex"] == "unknown"
async def test_version_change_blocks_resume_and_preserves_answers(released):
replies = await press(released, "intake:start")
await press(released, answer_button(replies))
await press(released, "intake:pause")
path = get_settings().config_dir / "instruments/intake_forms.yaml"
data = yaml.safe_load(path.read_text())
data["version"] = "synthetic-next-version"
path.write_text(yaml.safe_dump(data, allow_unicode=True))
await press(released, "intake:resume")
(row,) = await rows()
assert row.status == "review_required" and row.answers == {"cssrs_q1": 0}
async def test_followup_delta_uses_comparable_complete_baseline(released):
await complete_intake(released, {"trauma": 1, "phq9_1": 2, "tbi": 1, "psychotropics": 1})
async with session.get_clinical_session() as s:
task = await service.queue_task(s, TOKEN, "phq9", "synthetic-repeat", NOW)
task_id = task.id
replies = await released.handle(
TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id})
)
replies = await complete_existing(released, replies, {"phq9_1": 3, "phq9_2": 3, "phq9_3": 1})
repeat = next(r for r in await rows() if r.kind == "phq9")
assert repeat.status == "complete" and repeat.details["delta"]["value"] == 5
assert repeat.details["delta"]["retriage"] is True
async def test_silence_uses_completed_checkins_not_empty_records(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
async with session.get_clinical_session() as s:
(await repo.get_profile(s, TOKEN)).enhanced_monitoring = True
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2))
await jobs.plan()
await jobs.plan()
notices = await rows(QuestionnaireNotice)
assert len([n for n in notices if n.event_key.startswith("silence:")]) == 1
async def test_stop_any_policy_interrupts_first_positive(released):
path = get_settings().config_dir / "questionnaire.yaml"
data = yaml.safe_load(path.read_text())
data["cssrs_positive_policy"] = "stop_any"
path.write_text(yaml.safe_dump(data, allow_unicode=True))
replies = await press(released, "intake:start")
replies = await press(released, answer_button(replies, {"cssrs_q1": 0}))
assert any(r.kind == "crisis" for r in replies)
assert (await rows())[0].answers == {"cssrs_q1": 1}
async def test_evening_schedule_uses_moscow_date(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=15, minute=59))
await jobs.plan()
assert await rows(QuestionnaireTask) == []
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=16, minute=0))
await jobs.plan()
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary", "cssrs_since_contact"}
async def test_resumed_answer_keeps_original_period(released, monkeypatch):
replies = await press(released, "intake:start")
await press(released, answer_button(replies))
await press(released, "intake:pause")
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=1))
replies = await press(released, "intake:resume")
await press(released, answer_button(replies))
(record,) = await rows()
metadata = record.details["answer_metadata"]["cssrs_q2"]
assert metadata["period_start"] == "2026-09-03"
assert metadata["period_end"] == "2026-10-03"
assert metadata["answered_at"].startswith("2026-10-04")
+377
View File
@@ -0,0 +1,377 @@
"""Medication flow and check-in units (#37): reminder buttons, skip reasons, side effects, medication question,
med_info rule reactions, check-in edge cases, text policy of the YAML files."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
import pytest
import yaml
from sqlalchemy import select
from app.config import Settings, get_settings
from app.core import clock
from app.core.rules import Action
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Dose, Prescription
from app.dialog.contracts import Event, Reply
from app.dialog.engine import Engine
from app.dialog.flows.meds import load_meds_texts
from app.dialog.flows.veteran_daily import load_daily_texts
from app.dialog.fsm import Ctx, enter_hook_for
from app.dialog.scale_session import start_scale
from app.dialog.texts import load_dialog_texts
from app.integrations.base import DrugSection, integrations
from app.llm import med_qa as med_qa_module
from app.llm.gateway import LLMGateway
from app.llm.med_qa import fallback_text
from app.llm.mock import MockTasks
TOKEN = "m" * 32
MEM = "sqlite+aiosqlite:///:memory:"
NOW = datetime(2026, 10, 7, 8, 0)
MEDS = load_meds_texts()
DAILY = load_daily_texts()
SECTIONS = {
"side_effects": "Тошнота обычно возникает в первые дни приёма и проходит в течение 1–2 недель.",
"onset": "Улучшение обычно отмечается через 2–4 недели регулярного приёма.",
"alcohol": "Совместный приём с алкоголем не рекомендуется.",
"administration": "Препарат принимают один раз в сутки, утром или вечером, независимо от еды.",
}
class FakeDrugInfo:
async def sections(self, inn: str) -> list[DrugSection]:
return [DrugSection(inn=inn, section=k, text=v, source="синтетика") for k, v in SECTIONS.items()]
class ScriptedMonitoring:
def __init__(self) -> None:
self.calls = 0
self.actions: list[Action] = []
async def after_data_change(self, token: str) -> list[Action]:
self.calls += 1
out, self.actions = self.actions, []
return out
async def is_in_crisis(self, token: str) -> bool:
return False
@pytest.fixture
async def mon(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[ScriptedMonitoring]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
monkeypatch.setattr(integrations, "drug_info", FakeDrugInfo())
monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks()))
clock.set_now(NOW)
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with session.get_clinical_session() as s:
await repo.upsert_profile(
s, token=TOKEN, doctor_id="doc", modules=["veteran"], sex="m", birth_year=1990, therapy_start=NOW.date()
)
s.add(Prescription(token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}))
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True}, now=clock.now())
yield ScriptedMonitoring()
clock.reset()
await session.configure()
@pytest.fixture
def engine(mon: ScriptedMonitoring) -> Engine:
return Engine(gateway=LLMGateway(MockTasks()), monitoring=mon)
async def press(e: Engine, data: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="button", button=data))
async def text(e: Engine, t: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="text", text=t))
async def system(e: Engine, kind: str, **params: Any) -> list[Reply]:
return await e.handle(TOKEN, Event(type="system", system=kind, params=params)) # type: ignore[arg-type]
def buttons(replies: list[Reply]) -> list[str]:
return [b.data for r in replies for row in r.buttons for b in row]
def kinds(replies: list[Reply]) -> list[str]:
"""Button data without the dose payload (#56): "med:taken:7" → "med:taken"."""
return [d.rsplit(":", 1)[0] if d.startswith("med:") else d for d in buttons(replies)]
async def tap(e: Engine, replies: list[Reply], kind: str) -> list[Reply]:
"""Press the button of `replies` whose data (without payload) is `kind` — as the patient would."""
[data] = [d for d in buttons(replies) if d.startswith("med:") and d.rsplit(":", 1)[0] == kind]
return await press(e, data)
async def get_state() -> tuple[str, dict[str, Any]]:
async with session.get_clinical_session() as s:
row = await repo.get_dialog_state(s, TOKEN)
assert row is not None
return row.state, dict(row.data)
async def checkin() -> Any:
async with session.get_clinical_session() as s:
rows = await repo.list_checkins(s, TOKEN)
return rows[-1] if rows else None
async def doses() -> list[Dose]:
async with session.get_clinical_session() as s:
return list((await s.scalars(select(Dose).where(Dose.token == TOKEN).order_by(Dose.id))).all())
# ----------------------------------------------------------------------------------------------- reminder
async def test_reminder_without_dose_id_creates_dose_and_taken(engine: Engine, mon: ScriptedMonitoring) -> None:
r = await system(engine, "med_reminder")
assert r[0].text == MEDS.reminder.text.format(drug="сертралин", dose="50 мг")
[dose] = await doses()
assert buttons(r) == [f"med:taken:{dose.id}", f"med:skip:{dose.id}", f"med:side_effects:{dose.id}"]
assert dose.status == "pending"
r = await tap(engine, r, "med:taken")
assert r[0].text == MEDS.taken_ack and mon.calls == 1
[dose] = await doses()
assert dose.status == "taken" and dose.answered_at is not None
assert (await checkin()).med_taken is True
async def test_stale_reminder_button(engine: Engine) -> None:
reminder = await system(engine, "med_reminder")
clock.advance(days=1)
r = await tap(engine, reminder, "med:taken")
assert r[0].text == MEDS.stale and await checkin() is None
@pytest.mark.parametrize("reason", ["forgot", "other"])
async def test_skip_simple_reasons(engine: Engine, reason: str) -> None:
r = await tap(engine, await system(engine, "med_reminder"), "med:skip")
assert kinds(r) == [f"med:skip_reason:{x}" for x in ("forgot", "side_effects", "quit", "other")]
r = await tap(engine, r, f"med:skip_reason:{reason}")
assert r[0].text == MEDS.skip.ack[reason]
c = await checkin()
assert c.med_taken is False and not c.quit_intent
assert [d.status for d in await doses()] == ["skipped"]
async def test_skip_quit_sets_intent_and_explains(engine: Engine, mon: ScriptedMonitoring) -> None:
skip = await tap(engine, await system(engine, "med_reminder"), "med:skip")
mon.actions = [Action(type="med_info", code="quit_intent", params={"topic": "delayed_effect"})]
r = await tap(engine, skip, "med:skip_reason:quit")
assert r[0].text == MEDS.skip.ack["quit"]
assert "2–4 недели" in r[1].text and MEDS.med_info["delayed_effect"].notice in r[1].text
assert (await checkin()).quit_intent is True
async def test_skip_because_of_side_effects_asks_which(engine: Engine) -> None:
skip = await tap(engine, await system(engine, "med_reminder"), "med:skip")
r = await tap(engine, skip, "med:skip_reason:side_effects")
assert "med:se:nausea" in kinds(r) and "med:se:other" in kinds(r)
async def test_side_effect_button(engine: Engine, mon: ScriptedMonitoring) -> None:
r = await tap(engine, await system(engine, "med_reminder"), "med:side_effects")
assert kinds(r) == [f"med:se:{c}" for c in ("nausea", "insomnia", "headache", "dizziness", "agitation", "other")]
r = await tap(engine, r, "med:se:nausea")
assert "проходит в течение 1–2 недель" in r[0].text # instruction excerpt
assert r[1].text == MEDS.side_effects.ask_dose and kinds(r[1:]) == ["med:dose:yes", "med:dose:no"]
assert (await checkin()).side_effects == ["nausea"]
assert (await doses())[0].side_effects == "nausea"
r = await tap(engine, r, "med:dose:yes")
assert (await checkin()).med_taken is True and (await doses())[0].status == "taken"
async def test_side_effect_other_free_text(engine: Engine) -> None:
reminder = await system(engine, "med_reminder")
r = await tap(engine, await tap(engine, reminder, "med:side_effects"), "med:se:other")
assert r[0].text == MEDS.side_effects.describe and (await get_state())[0] == "meds.side_effect_text"
r = await text(engine, "голова болит весь день")
assert (await checkin()).side_effects == ["headache"] and (await get_state())[0] == "idle"
await tap(engine, await tap(engine, reminder, "med:side_effects"), "med:se:other")
await text(engine, "какое-то странное ощущение")
assert (await checkin()).side_effects == ["headache", "other"]
async def test_same_side_effect_as_yesterday_gets_support(engine: Engine) -> None:
async with session.get_clinical_session() as s:
await repo.upsert_checkin(
s, token=TOKEN, day=clock.today() - timedelta(days=1), now=clock.now(), side_effects=["nausea"]
)
se = await tap(engine, await system(engine, "med_reminder"), "med:side_effects")
r = await tap(engine, se, "med:se:nausea")
assert r[0].text == MEDS.side_effects.repeat
# ----------------------------------------------------------------------------------------------- question menu
async def test_med_question_menu(engine: Engine) -> None:
r = await press(engine, "menu:med_question")
assert r[0].text == MEDS.question.prompt and (await get_state())[0] == "meds.question"
r = await text(engine, "можно ли пить алкоголь?")
assert "Совместный приём с алкоголем не рекомендуется" in r[0].text and (await get_state())[0] == "idle"
await press(engine, "menu:med_question")
r = await text(engine, "можно увеличить дозу до 100 мг?")
assert r[0].text == fallback_text()
async def test_med_question_menu_left_by_button(engine: Engine) -> None:
await press(engine, "menu:med_question")
r = await press(engine, "menu:checkin")
assert r[0].text == DAILY.checkin.start and (await get_state())[0] == "daily.mood"
async def test_med_question_without_prescription(engine: Engine) -> None:
async with session.get_clinical_session() as s:
for row in await s.scalars(select(Prescription)):
await s.delete(row)
r = await press(engine, "menu:med_question")
assert r[0].text == MEDS.question.no_prescription and (await get_state())[0] == "idle"
async def test_med_info_without_excerpt_uses_fixed_text(
engine: Engine, mon: ScriptedMonitoring, monkeypatch: pytest.MonkeyPatch
) -> None:
class Empty:
async def sections(self, inn: str) -> list[DrugSection]:
return []
monkeypatch.setattr(integrations, "drug_info", Empty())
mon.actions = [Action(type="med_info", code="alcohol_3d", params={"topic": "alcohol_interaction"})]
r = await press(engine, "menu:visit") # any data-writing step runs monitoring
spec = MEDS.med_info["alcohol_interaction"]
assert r[0].text == DAILY.visit.requested
assert r[1].text == "\n\n".join([spec.lead, spec.fallback, spec.notice])
assert (await get_state())[1]["visit_requests"]
async def test_idle_free_text_med_question_and_side_effect(engine: Engine, mon: ScriptedMonitoring) -> None:
r = await text(engine, "когда подействуют таблетки?")
assert "2–4 недели" in r[0].text and "menu:checkin" in buttons(r)
r = await text(engine, "подташнивает после таблеток")
assert "проходит в течение 1–2 недель" in r[0].text and (await checkin()).side_effects == ["nausea"]
assert mon.calls == 1
# ----------------------------------------------------------------------------------------------- check-in edges
async def test_checkin_asks_dose_when_not_answered_and_typed_mood(engine: Engine) -> None:
await system(engine, "daily_checkin")
r = await text(engine, "отлично")
assert DAILY.checkin.buttons_hint in r[0].text and (await get_state())[0] == "daily.mood"
r = await text(engine, "7")
assert r[0].text == MEDS.checkin.question.format(drug="сертралин")
await press(engine, "daily:med:no")
c = await checkin()
assert (c.mood, c.med_taken) == (7, False)
assert (await get_state())[0] == "daily.sleep"
async def test_reminder_during_checkin_keeps_checkin(engine: Engine) -> None:
await system(engine, "daily_checkin")
r = await system(engine, "med_reminder")
assert kinds(r[:1]) == ["med:taken", "med:skip", "med:side_effects"] and r[-1].text == DAILY.checkin.mood.question
r = await tap(engine, r, "med:taken")
assert r[0].text == MEDS.taken_ack and (await get_state())[0] == "daily.mood"
await press(engine, "daily:mood:5")
assert (await get_state())[0] == "daily.sleep" # dose already answered → no med question
async def test_unfinished_checkin_dropped_next_day(engine: Engine) -> None:
await system(engine, "daily_checkin")
await press(engine, "daily:mood:2")
clock.advance(days=1)
r = await system(engine, "daily_checkin")
assert r[0].text == DAILY.checkin.start and (await get_state())[0] == "daily.mood"
async def test_checkin_once_a_day(engine: Engine) -> None:
await system(engine, "daily_checkin")
for b in ("daily:mood:6", "daily:med:yes", "daily:sleep:6-8", "daily:nightmares:no", "daily:alcohol:none"):
await press(engine, b)
await press(engine, "daily:text:skip")
assert await system(engine, "daily_checkin") == []
r = await press(engine, "menu:checkin")
assert r[0].text == DAILY.checkin.already_done
async def test_alcohol_clarify_for_non_veteran(engine: Engine) -> None:
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, modules=["depression"])
await system(engine, "daily_checkin")
await press(engine, "daily:mood:5")
r = await press(engine, "daily:med:yes")
assert buttons(r) == ["daily:text:skip"] # no sleep / alcohol questions outside the veteran module
r = await text(engine, "вечером выпил пива с друзьями")
assert r[-1].text == DAILY.checkin.alcohol.clarify and (await get_state())[0] == "daily.alcohol_amount"
r = await press(engine, "daily:alcohol_amount:more")
assert r[0].text == DAILY.checkin.done and (await checkin()).alcohol_bucket == "more"
async def test_buttons_win_over_llm(engine: Engine) -> None:
await system(engine, "daily_checkin")
for b in ("daily:mood:5", "daily:med:yes", "daily:sleep:6-8", "daily:nightmares:no", "daily:alcohol:none"):
await press(engine, b)
await text(engine, "почти не спал, кошмары, брошу таблетки")
c = await checkin()
assert (c.sleep_bucket, c.nightmares, c.alcohol_bucket, c.quit_intent) == ("6-8", False, "none", True)
assert c.free_text_scrubbed
async def test_scale_intro_not_added_to_onboarding(engine: Engine, mon: ScriptedMonitoring) -> None:
await system(engine, "scale_due", scale="dar5", scheduled=True)
state, data = await get_state()
assert state == "scale.dar5" and data["scales"]["dar5"]["intro"] == DAILY.scale_intros.scheduled["dar5"]
async with session.get_clinical_session() as s:
ctx = Ctx(
token=TOKEN,
state="idle",
data={},
now=clock.now(),
session=s,
gateway=LLMGateway(MockTasks()),
monitoring=mon,
texts=load_dialog_texts(),
)
step = await start_scale(ctx, "pcl5", return_state="veteran.onboarding.phq9")
ctx.data.update(step.data)
ctx.state = "scale.pcl5"
hook = enter_hook_for("scale.pcl5")
assert hook is not None
entered = await hook(ctx)
assert DAILY.scale_intros.scheduled["pcl5"] not in entered.replies[0].text
# ----------------------------------------------------------------------------------------------- text policy
def _strings(node: Any) -> list[str]:
if isinstance(node, str):
return [node]
if isinstance(node, dict):
return [s for v in node.values() for s in _strings(v)]
if isinstance(node, list):
return [s for v in node for s in _strings(v)]
return []
@pytest.mark.parametrize("name", ["meds.yaml", "veteran_daily.yaml"])
def test_texts_policy(name: str) -> None:
path: Path = get_settings().config_dir / "texts" / name
raw = path.read_text(encoding="utf-8")
assert "черновик, утверждает психиатр (#8)" in raw.splitlines()[2]
values = " ".join(_strings(yaml.safe_load(raw))).lower()
for word in ("учёт", "учет", "контрол", "надзор", "диагноз", "птср", "депресси", "увеличьте доз", "мг"):
assert word not in values, (name, word)
+391
View File
@@ -0,0 +1,391 @@
"""Acceptance: 13 days of the veteran Andrey (docs/product/01_demo_persona.md) through the real dialog engine.
Andrey is set up post-onboarding directly in the DBs (no dependency on #36): modules veteran + depression,
therapy day 0, sertraline 50 mg at 08:00, consent. Each day: the dose reminder (as the scheduler would send it)
→ button; the daily check-in → buttons + the free text from the table.
Lane B's MonitoringService (#38) is replaced by a double that builds a `PatientSnapshot` from the clinical DB,
runs the REAL `app.core.rules.evaluate`, persists alert/crisis actions (so dedup works) and returns the dialog
actions — the same split as the frozen contract (ADR-007). Drug instruction excerpts are synthetic (FakeDrugInfo).
"""
from collections.abc import AsyncIterator
from datetime import date, datetime, timedelta
from typing import Any
import pytest
from sqlalchemy import func, select
from app.config import Settings
from app.core import clock
from app.core.rules import Action, DailyCheckin, DoseRecord, PatientSnapshot, ScalePoint, evaluate, patient_status
from app.core.scales import load_scale
from app.core.types import AlertLevel
from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Dose, Message, Prescription
from app.dialog.contracts import Event, Reply
from app.dialog.engine import Engine
from app.dialog.flows.meds import load_meds_texts
from app.dialog.flows.veteran_daily import load_daily_texts
from app.integrations.base import DrugSection, integrations
from app.llm import med_qa as med_qa_module
from app.llm.gateway import LLMGateway
from app.llm.mock import MockTasks
from app.services import contracts as monitoring_contracts
TOKEN = "e" * 32
MEM = "sqlite+aiosqlite:///:memory:"
DAY0 = datetime(2026, 10, 5, 8, 0) # visit day; reminders at 08:00, check-ins at 20:00 (Europe/Moscow)
SRC = "ГРЛС: тестовая выдержка (синтетика)"
SERTRALINE = {
"side_effects": (
"Наиболее частые нежелательные реакции в начале терапии: тошнота, диарея, сухость во рту. "
"Тошнота обычно возникает в первые дни приёма и проходит в течение 1–2 недель. "
"Для уменьшения тошноты препарат принимают после еды."
),
"onset": (
"Терапевтический эффект развивается постепенно. "
"Улучшение обычно отмечается через 2–4 недели регулярного приёма. "
"Суточная доза может увеличиваться врачом до 200 мг."
),
"alcohol": (
"Совместный приём с алкоголем не рекомендуется. "
"Алкоголь может ухудшать сон и настроение и усиливать побочные эффекты."
),
}
# Persona table: day → (dose taken, mood, sleep, nightmares, alcohol, free text). "" = skip the free text.
TABLE: dict[int, tuple[bool, int, str, bool, str, str]] = {
1: (True, 4, "4-6", True, "none", ""),
2: (True, 4, "4-6", True, "none", "подташнивает с утра"),
3: (True, 4, "4-6", True, "none", "опять тошнит"),
4: (True, 3, "4-6", False, "none", "тошнота не проходит"),
5: (False, 3, "4-6", False, "none", "от таблеток только хуже, брошу"),
6: (True, 4, "6-8", False, "none", "врач позвонил, продолжаю"),
7: (True, 4, "6-8", False, "none", ""),
8: (True, 4, "<4", True, "1-2", "снится одно и то же"),
9: (True, 4, "<4", True, "more", ""),
10: (True, 4, "<4", True, "more", "без пива не могу уснуть"),
11: (True, 3, "4-6", False, "1-2", ""),
12: (True, 3, "4-6", False, "none", ""),
13: (True, 2, "<4", True, "none", "всё бессмысленно, устал от всего"),
}
# Expected dashboard colour at the end of each day (persona: 🟢🟢🟢🟡🟡🟢🟢🟢🟢🟡🟡🟡🔴).
COLORS = ["green"] * 3 + ["yellow"] * 2 + ["green"] * 4 + ["yellow"] * 3 + ["red"]
class FakeDrugInfo:
async def sections(self, inn: str) -> list[DrugSection]:
if inn != "сертралин":
return []
return [DrugSection(inn=inn, section=k, text=v, source=SRC) for k, v in SERTRALINE.items()]
class RuleMonitoring:
"""Test double of lane B's MonitoringService: real rules over a snapshot of the clinical DB."""
def __init__(self) -> None:
self.fired: list[str] = []
async def _snapshot(self, s: Any, token: str) -> PatientSnapshot:
now = clock.now()
profile = await repo.get_profile(s, token)
assert profile is not None and profile.therapy_start is not None and profile.birth_year is not None
checkins = [
DailyCheckin(
day=c.day,
mood=c.mood,
med_taken=c.med_taken,
sleep_hours_bucket=c.sleep_bucket, # type: ignore[arg-type]
alcohol=c.alcohol_bucket, # type: ignore[arg-type]
side_effects=list(c.side_effects or []),
quit_intent=bool(c.quit_intent),
nightmares=c.nightmares,
)
for c in await repo.list_checkins(s, token)
]
results = [r for r in await repo.list_scale_results(s, token) if r.complete]
doses = (await s.scalars(select(Dose).where(Dose.token == token, Dose.status != "pending"))).all()
cssrs = [r for r in results if r.scale == "cssrs_screen"]
last_in = await s.scalar(select(func.max(Message.ts)).where(Message.token == token, Message.direction == "in"))
return PatientSnapshot(
token=token,
as_of=now,
age=now.year - profile.birth_year,
sex=profile.sex, # type: ignore[arg-type]
modules=set(profile.modules), # type: ignore[arg-type]
therapy_day=(now.date() - profile.therapy_start).days,
checkins=checkins,
scale_results=[
ScalePoint(scale=r.scale, ts=r.ts, total=r.total, item9=r.item9, scheduled=r.scheduled)
for r in results
if r.scale != "cssrs_screen"
],
doses=[DoseRecord(planned_at=d.planned_at, status=d.status) for d in doses], # type: ignore[arg-type]
cssrs_risk=cssrs[-1].severity if cssrs else None, # type: ignore[arg-type]
weapon_access=False,
last_patient_message_at=last_in,
open_alert_codes=await repo.open_alert_codes(s, token),
)
async def after_data_change(self, token: str) -> list[Action]:
async with session.get_clinical_session() as s:
actions = evaluate(await self._snapshot(s, token))
for a in actions:
if a.creates_alert:
assert a.level is not None
await repo.add_alert(
s, token=token, level=a.level, code=a.code, reason=a.reason_ru, ts=clock.now(), params=a.params
)
self.fired += [a.code for a in actions]
return [a for a in actions if a.type != "alert"]
async def is_in_crisis(self, token: str) -> bool:
async with session.get_clinical_session() as s:
alerts = await repo.list_alerts(s, token=token, open_only=True)
return any(a.level == "acute" for a in alerts)
# ----------------------------------------------------------------------------------------------- fixtures
@pytest.fixture
async def andrey(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[RuleMonitoring]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
monkeypatch.setattr(integrations, "drug_info", FakeDrugInfo())
monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks()))
clock.set_now(DAY0)
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
now = clock.now()
async with session.get_identity_session() as s:
await identity_repo.create_patient(
s,
token=TOKEN,
display_name="Андрей С. (синтетика)",
snils="000-000-000 00",
clinic_id="c1",
doctor_id="doc-smirnova",
now=now,
birth_year=1992,
sex="m",
)
await identity_repo.record_consent(s, token=TOKEN, version="v1", now=now)
async with session.get_clinical_session() as s:
await repo.upsert_profile(
s,
token=TOKEN,
doctor_id="doc-smirnova",
modules=["veteran", "depression"],
therapy_start=now.date(),
next_visit=now.date() + timedelta(days=28),
birth_year=1992,
sex="m",
)
s.add(
Prescription(
token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}, start_date=now.date()
)
)
screening = now - timedelta(days=3) # day −3 screening (persona)
for scale, total in (("pcptsd5", 4), ("pcl5", 46), ("phq9", 14), ("auditc", 3)):
await repo.add_scale_result(
s,
token=TOKEN,
scale=scale,
total=total,
severity="-",
complete=True,
ts=screening,
item9=0 if scale == "phq9" else None,
)
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=now)
mon = RuleMonitoring()
monitoring_contracts.get_monitoring() # import lane B's module first if it exists, then override it
previous = monitoring_contracts._factory
monitoring_contracts.register_monitoring(lambda: mon)
yield mon
monitoring_contracts.register_monitoring(previous)
clock.reset()
await session.configure()
# ----------------------------------------------------------------------------------------------- helpers
class Andrey:
def __init__(self) -> None:
self.engine = Engine(gateway=LLMGateway(MockTasks())) # monitoring resolved via register_monitoring
async def text(self, t: str) -> list[Reply]:
return await self.engine.handle(TOKEN, Event(type="text", text=t))
async def press(self, data: str) -> list[Reply]:
return await self.engine.handle(TOKEN, Event(type="button", button=data))
async def system(self, kind: str, **params: Any) -> list[Reply]:
return await self.engine.handle(TOKEN, Event(type="system", system=kind, params=params)) # type: ignore[arg-type]
async def answer_scale(self, scale_id: str, values: list[int]) -> list[Reply]:
out: list[Reply] = []
for item, value in zip(load_scale(scale_id).items, values, strict=True):
out += await self.press(f"scale:{scale_id}:{item.id}:{value}")
return out
def buttons(replies: list[Reply]) -> list[str]:
return [b.data for r in replies for row in r.buttons for b in row]
def joined(replies: list[Reply]) -> str:
return "\n".join(r.text for r in replies)
async def state() -> tuple[str, dict[str, Any]]:
async with session.get_clinical_session() as s:
row = await repo.get_dialog_state(s, TOKEN)
assert row is not None
return row.state, dict(row.data)
async def open_alerts() -> list[Any]:
async with session.get_clinical_session() as s:
return list(await repo.list_alerts(s, token=TOKEN, open_only=True))
async def ack_all() -> None:
async with session.get_clinical_session() as s:
for a in await repo.list_alerts(s, token=TOKEN, open_only=True):
await repo.ack_alert(s, alert_id=a.id, doctor_id="doc-smirnova", ts=clock.now())
async def new_dose(day: date) -> int:
"""What the scheduler (#13) does before sending the reminder: a planned dose row."""
async with session.get_clinical_session() as s:
rx = await s.scalar(select(Prescription).where(Prescription.token == TOKEN))
assert rx is not None
row = Dose(token=TOKEN, prescription_id=rx.id, planned_at=clock.now(), status="pending")
s.add(row)
await s.flush()
return row.id
# ----------------------------------------------------------------------------------------------- the 13 days
async def test_andrey_13_days(andrey: RuleMonitoring) -> None:
a = Andrey()
meds = load_meds_texts()
daily = load_daily_texts()
log: dict[int, list[Reply]] = {}
colors: list[str] = []
for day in range(1, 14):
taken, mood, sleep, nightmares, alcohol, free_text = TABLE[day]
clock.set_now(DAY0 + timedelta(days=day))
if day == 6:
await ack_all() # the doctor called Andrey after the day-5 alerts and acknowledged them
r: list[Reply] = []
# --- 08:00 dose reminder
dose_id = await new_dose(clock.today())
reminder = await a.system("med_reminder", dose_id=dose_id, drug="сертралин", dose="50 мг")
assert "сертралин 50 мг" in reminder[0].text
assert buttons(reminder) == [f"med:taken:{dose_id}", f"med:skip:{dose_id}", f"med:side_effects:{dose_id}"]
if taken:
r += await a.press(f"med:taken:{dose_id}")
else:
skip = await a.press(f"med:skip:{dose_id}")
assert f"med:skip_reason:quit:{dose_id}" in buttons(skip)
r += skip + await a.press(f"med:skip_reason:quit:{dose_id}")
# --- day 7: scheduled scales (PCL-5 = 44, PHQ-9 = 13, item 9 = 0) with friendly intros
if day == 7:
first = await a.system("scale_due", scale="pcl5", scheduled=True)
assert daily.scale_intros.scheduled["pcl5"] in first[0].text
await a.system("scale_due", scale="phq9", scheduled=True) # queued behind PCL-5
after_pcl = await a.answer_scale("pcl5", [2] * 16 + [3] * 4)
assert daily.scale_intros.scheduled["phq9"] in after_pcl[-1].text
await a.answer_scale("phq9", [2] * 5 + [1] * 3 + [0])
assert (await state())[0] == "idle"
# --- 20:00 daily check-in
clock.set_now(DAY0 + timedelta(days=day, hours=12))
start = await a.system("daily_checkin")
assert start[0].text == daily.checkin.start and buttons(start)[0] == "daily:mood:0"
r += await a.press(f"daily:mood:{mood}") # dose already answered by the reminder → no med question
assert "daily:sleep:<4" in buttons(r[-1:])
r += await a.press(f"daily:sleep:{sleep}")
r += await a.press(f"daily:nightmares:{'yes' if nightmares else 'no'}")
r += await a.press(f"daily:alcohol:{alcohol}")
assert buttons(r[-1:]) == ["daily:text:skip"]
r += await a.text(free_text) if free_text else await a.press("daily:text:skip")
log[day] = r
if day == 10: # «записаться раньше» offered by rule alcohol_3d
visit = await a.press("menu:visit")
assert visit[0].text == daily.visit.requested and (await state())[1]["visit_requests"]
if day == 13:
break # the crisis path is checked below
assert (await state())[0] == "idle"
colors.append(patient_status(AlertLevel[x.level.upper()] for x in await open_alerts()).color)
# Day 2: first nausea → instruction excerpt (med_qa, section side_effects), not generated advice.
assert "По инструкции к препарату" in joined(log[2]) and "проходит в течение 1–2 недель" in joined(log[2])
# Day 3: same effect again → short support, the instruction is not repeated.
assert meds.side_effects.repeat in joined(log[3]) and "По инструкции" not in joined(log[3])
# Day 5: «решил бросить» → rule quit_intent → delayed onset + do not stop on your own + doctor notified.
day5 = joined(log[5])
assert "2–4 недели" in day5 and "не прекращайте" in day5 and meds.med_info["delayed_effect"].notice in day5
assert "200 мг" not in day5 # dosing sentences of the instruction are never quoted
# Day 10: sleep < 4 h ×3 + alcohol ×3 → interaction info + «записаться раньше».
day10 = joined(log[10])
assert "Совместный приём с алкоголем не рекомендуется" in day10
assert buttons(log[10])[-1] == "menu:visit"
# Day 11: alerts still open → the rule stays silent (no repeated interaction text).
assert "Совместный приём с алкоголем" not in joined(log[11]) and "menu:visit" not in buttons(log[11])
assert colors == COLORS[:12]
# Day 13: «всё бессмысленно» → POSSIBLE → C-SSRS screen (buttons only) took over the free text.
day13 = log[13]
assert day13[-1].kind == "question" and "scale:cssrs_screen:cssrs_1:0" in buttons(day13[-1:])
r = await a.answer_scale("cssrs_screen", [0] * 6)
# Back in the check-in: the message counts as the answer → check-in closed → mood ≤ 3 three days →
# unscheduled PHQ-9 with its own intro.
assert daily.checkin.done in joined(r) and daily.scale_intros.unscheduled["phq9"] in r[-1].text
st, data = await state()
assert st == "scale.phq9" and data["scales"]["phq9"]["scheduled"] is False
r = await a.answer_scale("phq9", [2] * 8 + [2]) # total 18, item 9 = 2
assert any(x.kind == "crisis" for x in r) and any(x.kind == "safety_plan" for x in r)
st, data = await state()
# C-SSRS was completed minutes ago: item 9 opens the crisis but does not ask the same screen again (#56).
assert data["crisis"]["open"] is True and st == "crisis" and not data.get("pending_scales")
async with session.get_clinical_session() as s:
assert len(await repo.list_scale_results(s, TOKEN, "cssrs_screen")) == 1
acute = [x for x in await open_alerts() if x.level == "acute"]
assert [x.code for x in acute] == ["phq9_item9"]
assert patient_status(AlertLevel[x.level.upper()] for x in await open_alerts()).color == COLORS[12]
# Stored check-ins match the persona table.
async with session.get_clinical_session() as s:
rows = {c.day: c for c in await repo.list_checkins(s, TOKEN)}
doses = (await s.scalars(select(Dose).where(Dose.token == TOKEN).order_by(Dose.id))).all()
phq = list(await repo.list_scale_results(s, TOKEN, "phq9"))
for day, (taken, mood, sleep, nightmares, alcohol, _text) in TABLE.items():
c = rows[(DAY0 + timedelta(days=day)).date()]
assert (c.med_taken, c.mood, c.sleep_bucket, c.nightmares, c.alcohol_bucket) == (
taken,
mood,
sleep,
nightmares,
alcohol,
), day
for day in (2, 3, 4):
assert "nausea" in rows[(DAY0 + timedelta(days=day)).date()].side_effects
assert rows[(DAY0 + timedelta(days=5)).date()].quit_intent is True
assert rows[(DAY0 + timedelta(days=13)).date()].free_text_scrubbed is None # taken over by the C-SSRS
assert [d.status for d in doses] == ["skipped" if d == 5 else "taken" for d in range(1, 14)]
assert [(p.total, p.item9, p.scheduled) for p in phq] == [(14, 0, True), (13, 0, True), (18, 2, False)]
for code in ("side_effect_3d", "quit_intent", "sleep_lt4_3n", "alcohol_3d", "mood_low_3d", "phq9_item9"):
assert code in andrey.fired, code
+368
View File
@@ -0,0 +1,368 @@
"""Veteran start screening (#36): demo persona Andrey end-to-end through `Engine.handle` on the offline mock LLM.
Connect code → consent → age (known) → intro → trauma exposure → PC-PTSD-5 → PCL-5 → PHQ-9 → AUDIT-C → weapon
question → plain-language result → idle. In-memory SQLite (identity / clinical), fake monitoring (lane B stub).
"""
import re
from collections.abc import AsyncIterator
from pathlib import Path
from typing import Any
import pytest
import yaml
from app.core import clock
from app.core.rules import Action
from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.dialog.contracts import Event, Reply
from app.dialog.engine import Engine
from app.dialog.flows import veteran_onboarding as vo
from app.dialog.texts import load_dialog_texts
from app.integrations.base import VerifiedPerson, integrations
from app.llm.gateway import LLMGateway
from app.llm.mock import MockTasks
TOKEN = "b" * 32
MEM = "sqlite+aiosqlite:///:memory:"
TEXTS = load_dialog_texts()
VT = vo.load_texts()
# Andrey's screening (docs/product/01_demo_persona.md): PC-PTSD-5 = 4, PCL-5 = 46, PHQ-9 = 14 (item 9 = 0),
# AUDIT-C = 3 (below the male threshold), no weapon at home.
PCPTSD5 = [1, 1, 1, 1, 0]
PCL5 = [3] * 6 + [2] * 14
PHQ9 = [2, 2, 2, 2, 2, 2, 1, 1, 0]
AUDITC = [2, 1, 0]
class FakeMonitoring:
def __init__(self) -> None:
self.calls = 0
async def after_data_change(self, token: str) -> list[Action]:
self.calls += 1
return []
async def is_in_crisis(self, token: str) -> bool:
return True # only doctor_ack ends a crisis in these tests
async def _make_patient(sex: str | None) -> None:
async with session.get_identity_session() as s:
await identity_repo.create_patient(
s,
token=TOKEN,
display_name="Андрей (синтетика)",
snils="000-000-000 00",
clinic_id="c1",
doctor_id="doc-test",
now=clock.now(),
birth_year=1988,
sex=sex or "m",
)
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-test", modules=["veteran"], sex=sex)
@pytest.fixture
async def dbs() -> AsyncIterator[None]:
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
await _make_patient("m")
yield
await session.configure()
@pytest.fixture
async def dbs_nosex() -> AsyncIterator[None]:
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
await _make_patient(None)
yield
await session.configure()
@pytest.fixture
def mon() -> FakeMonitoring:
return FakeMonitoring()
@pytest.fixture
def engine(mon: FakeMonitoring) -> Engine:
return Engine(gateway=LLMGateway(MockTasks()), monitoring=mon)
# ----------------------------------------------------------------------------------------------- helpers
async def press(e: Engine, data: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="button", button=data))
async def text(e: Engine, t: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="text", text=t))
def buttons(reply: Reply) -> list[str]:
return [b.data for row in reply.buttons for b in row]
def scale_buttons(replies: list[Reply], scale: str) -> list[str]:
for reply in reversed(replies):
found = [d for d in buttons(reply) if d.startswith(f"scale:{scale}:")]
if found:
return found
raise AssertionError(f"no question of {scale} in replies: {[r.text[:40] for r in replies]}")
async def answer(e: Engine, replies: list[Reply], scale: str, values: list[int]) -> list[Reply]:
"""Answer `values` item by item with the buttons of the currently shown question."""
for value in values:
choice = [d for d in scale_buttons(replies, scale) if d.endswith(f":{value}")]
assert choice, f"{scale}: no option {value}"
replies = await press(e, choice[0])
return replies
async def get_state() -> tuple[str, dict[str, Any]]:
async with session.get_clinical_session() as s:
row = await repo.get_dialog_state(s, TOKEN)
assert row is not None
return row.state, dict(row.data)
async def results() -> dict[str, Any]:
async with session.get_clinical_session() as s:
return {r.scale: r for r in await repo.list_scale_results(s, TOKEN)}
async def profile() -> Any:
async with session.get_clinical_session() as s:
return await repo.get_profile(s, TOKEN)
async def to_intro(e: Engine) -> list[Reply]:
await e.handle(TOKEN, Event(type="start"))
return await press(e, "consent:yes")
async def to_weapon(e: Engine, phq9: list[int] = PHQ9) -> list[Reply]:
await to_intro(e)
r = await press(e, "vonb:go:yes")
assert buttons(r[-1]) == ["vonb:exposure:yes", "vonb:exposure:no", "vonb:exposure:declined"]
r = await press(e, "vonb:exposure:yes")
r = await answer(e, r, "pcptsd5", PCPTSD5)
r = await answer(e, r, "pcl5", PCL5)
r = await answer(e, r, "phq9", phq9)
return r
# ----------------------------------------------------------------------------------------------- happy path
async def test_andrey_full_onboarding(
dbs: None, engine: Engine, mon: FakeMonitoring, monkeypatch: pytest.MonkeyPatch
) -> None:
class Ident:
async def verify(self, auth_code: str) -> VerifiedPerson | None:
return VerifiedPerson(token=TOKEN, doctor_id="doc-test") if auth_code == "QR-ANDREY" else None
monkeypatch.setattr(integrations, "identity", Ident())
r = await engine.handle(TOKEN, Event(type="start", params={"code": "QR-ANDREY"}))
assert buttons(r[0]) == ["consent:yes", "consent:no"]
r = await press(engine, "consent:yes") # birth year known → adult → veteran onboarding
assert (await get_state())[0] == vo.START
assert r[-1].text == VT.intro.text
assert buttons(r[-1]) == ["vonb:go:yes", "vonb:faq:who", "vonb:faq:unit", "vonb:faq:licences"]
r = await press(engine, "vonb:go:yes")
assert (await get_state())[0] == vo.EXPOSURE
r = await press(engine, "vonb:exposure:yes")
assert (await get_state())[0] == "scale.pcptsd5"
r = await answer(engine, r, "pcptsd5", PCPTSD5)
assert (await get_state())[0] == "scale.pcl5"
r = await answer(engine, r, "pcl5", PCL5)
assert (await get_state())[0] == "scale.phq9"
r = await answer(engine, r, "phq9", PHQ9)
assert (await get_state())[0] == "scale.auditc" # sex known from profile → no question
r = await answer(engine, r, "auditc", AUDITC)
assert (await get_state())[0] == vo.WEAPON
weapon_choices = ["none", "self", "others_or_safe", "declined"]
assert buttons(r[-1]) == [f"vonb:weapon:{c}" for c in weapon_choices]
r = await press(engine, "vonb:weapon:none")
state, data = await get_state()
assert state == "idle"
texts = [x.text for x in r]
assert VT.weapon.ack in texts
summary = next(x for x in r if x.text.startswith(VT.result.intro))
assert VT.result.ptsd in summary.text and VT.result.mood in summary.text
assert VT.result.alcohol not in summary.text # AUDIT-C 3 < 4 for men
assert not re.search(r"\d", summary.text) # no scores shown to the patient
assert r[-1].text == TEXTS.idle.welcome and "menu:checkin" in buttons(r[-1])
res = await results()
assert {k: v.total for k, v in res.items()} == {"pcptsd5": 4, "pcl5": 46, "phq9": 14, "auditc": 3}
assert res["phq9"].item9 == 0 and res["auditc"].severity == "negative"
assert all(v.complete and v.scheduled for v in res.values())
assert (await profile()).modules == ["veteran", "depression"]
facts = data["facts"]
assert facts["trauma_exposure"] is True
assert facts["weapon_access"] is False and facts["weapon_access_choice"] == "none"
assert facts["weapon_safety_block"] is False and "weapon_access_at" in facts
assert data["veteran_onboarding"].get("stage") is None and "done_at" in data["veteran_onboarding"]
assert "crisis" not in data
assert mon.calls >= 5 # every scale + weapon + result
async def test_exposure_no_skips_pcptsd5(dbs: None, engine: Engine) -> None:
await to_intro(engine)
await press(engine, "vonb:go:yes")
r = await press(engine, "vonb:exposure:no")
assert (await get_state())[0] == "scale.pcl5" # veterans get PCL-5 regardless
assert VT.exposure.ack_other in [x.text for x in r]
r = await answer(engine, r, "pcl5", [0] * 20)
r = await answer(engine, r, "phq9", [0] * 9)
r = await answer(engine, r, "auditc", [0, 0, 0])
r = await press(engine, "vonb:weapon:declined")
state, data = await get_state()
assert state == "idle"
assert "pcptsd5" not in await results()
assert data["facts"]["trauma_exposure"] is False
assert data["facts"]["weapon_access"] is None and data["facts"]["weapon_access_choice"] == "declined"
assert any(VT.result.calm in x.text for x in r)
assert (await profile()).modules == ["veteran"] # PHQ-9 0 < 10 → no depression module
async def test_exposure_declined_and_free_text_reasks(dbs: None, engine: Engine) -> None:
await to_intro(engine)
await press(engine, "vonb:go:yes")
r = await text(engine, "ну такое")
assert (await get_state())[0] == vo.EXPOSURE
assert VT.buttons_hint in r[-1].text and "vonb:exposure:yes" in buttons(r[-1])
await press(engine, "vonb:exposure:declined")
state, data = await get_state()
assert state == "scale.pcl5" and data["facts"]["trauma_exposure"] is None
async def test_auditc_asks_sex_when_unknown(dbs_nosex: None, engine: Engine) -> None:
await to_weapon(engine)
assert (await get_state())[0] == vo.AUDITC
r = await press(engine, "vonb:sex:f")
assert (await get_state())[0] == "scale.auditc"
assert (await profile()).sex == "f"
await answer(engine, r, "auditc", AUDITC)
res = await results()
assert res["auditc"].severity == "positive" # 3 is positive for women
async def test_auditc_sex_declined_uses_default_threshold(dbs_nosex: None, engine: Engine) -> None:
await to_weapon(engine)
r = await press(engine, "vonb:sex:declined")
assert (await profile()).sex is None
r = await answer(engine, r, "auditc", AUDITC)
assert (await results())["auditc"].severity == "positive" # unknown sex → sensitive threshold ≥ 3
# Weapon at home + AUDIT-C positive = risk signal → safe-storage block.
r = await press(engine, "vonb:weapon:self")
assert VT.weapon.safety_block_self in [x.text for x in r]
# ----------------------------------------------------------------------------------------------- weapon / safety
async def test_weapon_without_risk_no_block(dbs: None, engine: Engine) -> None:
r = await to_weapon(engine)
r = await answer(engine, r, "auditc", AUDITC)
r = await press(engine, "vonb:weapon:others_or_safe")
texts = [x.text for x in r]
assert VT.weapon.safety_block_stored not in texts and VT.weapon.safety_block_self not in texts
_, data = await get_state()
assert data["facts"]["weapon_access"] is True and data["facts"]["weapon_access_choice"] == "others_or_safe"
assert data["facts"]["weapon_safety_block"] is False
async def test_item9_positive_crisis_then_resume_and_safety_block(dbs: None, engine: Engine) -> None:
r = await to_weapon(engine, phq9=[2, 2, 2, 2, 2, 2, 1, 1, 1])
state, data = await get_state()
# Engine crisis path: fixed script, C-SSRS queued and started while in crisis.
assert data["crisis"]["open"] is True and data["crisis"]["source"] == "scale:phq9"
assert any(x.kind == "crisis" for x in r)
assert state == "scale.cssrs_screen"
r = await answer(engine, r, "cssrs_screen", [0] * 6)
assert (await get_state())[0] == "crisis"
# Doctor acknowledged → idle → the onboarding resumes where it stopped (PHQ-9 already stored → AUDIT-C).
r = await engine.handle(TOKEN, Event(type="system", system="doctor_ack"))
assert VT.intro.resume in [x.text for x in r]
assert (await get_state())[0] == "scale.auditc"
r = await answer(engine, r, "auditc", AUDITC)
r = await press(engine, "vonb:weapon:self")
state, data = await get_state()
assert VT.weapon.safety_block_self in [x.text for x in r]
assert data["facts"]["weapon_access"] is True and data["facts"]["weapon_safety_block"] is True
assert state == "idle"
assert (await results())["phq9"].item9 == 1
# ----------------------------------------------------------------------------------------------- FAQ
async def test_faq_in_onboarding_and_idle(dbs: None, engine: Engine) -> None:
await to_intro(engine)
for key in vo.FAQ_KEYS:
r = await press(engine, f"vonb:faq:{key}")
assert r[0].text == getattr(VT.faq, key).text
assert "vonb:go:yes" in buttons(r[-1]) # the intro is shown again
assert (await get_state())[0] == vo.START
await press(engine, "vonb:go:yes")
r = await press(engine, "vonb:faq:unit")
assert r[0].text == VT.faq.unit.text and "vonb:exposure:yes" in buttons(r[-1])
# FAQ buttons keep working after onboarding (idle triggers).
r = await answer(engine, await press(engine, "vonb:exposure:no"), "pcl5", [0] * 20)
r = await answer(engine, r, "phq9", [0] * 9)
r = await answer(engine, r, "auditc", [0, 0, 0])
await press(engine, "vonb:weapon:none")
assert (await get_state())[0] == "idle"
r = await press(engine, "vonb:faq:licences")
assert r[0].text == VT.faq.licences.text and "menu:checkin" in buttons(r[0])
def test_faq_texts_honest() -> None:
assert "прямо предусмотренных законом" in VT.faq.unit.text
assert "лечащий врач" in VT.faq.who.text
raw = Path(vo.texts_path()).read_text(encoding="utf-8")
assert raw.count("формулировку проверяет юрист") >= 3
assert VT.status == "черновик, утверждает психиатр (#8)"
# ----------------------------------------------------------------------------------------------- texts scan
FORBIDDEN = ["диагноз", "расстройств", "психолог", "психиатр", "болезн", "псих"]
FORBIDDEN_SERVICE = ["учёт", "учет", "контрол", "надзор"]
def _strings(node: Any, path: str = "") -> list[tuple[str, str]]:
if isinstance(node, dict):
return [s for k, v in node.items() for s in _strings(v, f"{path}.{k}")]
if isinstance(node, list):
return [s for v in node for s in _strings(v, path)]
return [(path, node)] if isinstance(node, str) else []
def test_yaml_forbidden_words() -> None:
data = yaml.safe_load(Path(vo.texts_path()).read_text(encoding="utf-8"))
strings = [(p, s) for p, s in _strings(data) if p != ".status"]
assert strings
for path, value in strings:
low = value.lower()
for word in FORBIDDEN:
assert word not in low, f"{path}: «{word}»"
# Quoting the patient's own fear in a FAQ button label is allowed; statements about the service are not.
if not (path.startswith(".faq.") and path.endswith(".button")):
for word in FORBIDDEN_SERVICE:
assert word not in low, f"{path}: «{word}»"
+110
View File
@@ -0,0 +1,110 @@
"""Synthetic exhaustive scoring and calendar/diary boundary checks."""
from datetime import date, timedelta
from itertools import product
import pytest
from app.core.intake_runtime import diary_flags, due_scales, load_runtime, month_before
from app.core.questionnaire import IntakePolicy, alcohol_expansion, load_policy, score_alcohol
from app.core.scales import score
def test_release_is_closed():
assert not load_runtime().released
assert load_policy().approval is None
def test_all_tsq_patterns():
for answers in product((0, 1), repeat=10):
result = score("tsq", {f"tsq_{i + 1}": v for i, v in enumerate(answers)})
assert result.complete and result.total == sum(answers)
assert (result.severity_code == "positive") == (sum(answers) >= 6)
@pytest.mark.parametrize(
"day,expected",
[
(date(2024, 3, 31), date(2024, 2, 29)),
(date(2025, 3, 31), date(2025, 2, 28)),
(date(2026, 1, 1), date(2025, 12, 1)),
],
)
def test_calendar_month(day, expected):
assert month_before(day) == expected
def test_medication_schedule_overlapping_and_delayed_completion():
start = date(2026, 1, 1)
last = {}
for day in (6, 7, 13, 14, 27, 28, 55, 56):
due = due_scales(
today=start + timedelta(days=day), enrolled=start, last=last, route="self_help", medication_changed=start
)
assert ("phq9" in due) == (day in (7, 14, 28, 56))
if "phq9" in due:
last.update(phq9=start + timedelta(days=day), pcl5=start + timedelta(days=day))
# A missed tick yields one form, not each historical milestone.
assert (
due_scales(
today=start + timedelta(days=80), enrolled=start, last={}, route="self_help", medication_changed=start
).count("phq9")
== 1
)
assert "phq9" in due_scales(
today=start + timedelta(days=7), enrolled=start, last={}, route="specialist", after_crisis=start
)
def test_diary_consecutive_days_and_baseline_do_not_include_bad_window():
today = date(2026, 10, 3)
history = {today - timedelta(days=i): {"mood": 8} for i in range(3, 10)}
for i in range(3):
history[today - timedelta(days=i)] = {"mood": 5, "sleep_hours": 3.9, "medication": "none"}
assert set(diary_flags(history, today)) == {"phq9_due_mood", "sleep_review", "medication_contact"}
history[today - timedelta(days=1)] = {}
assert diary_flags(history, today) == ()
history[today]["death_thoughts"] = 1
assert diary_flags(history, today) == ("suicide_assessment_now",)
def test_sleep_alternation_counts_but_threshold_is_strict():
today = date(2026, 10, 3)
h = {
today: {"nightmares": 3},
today - timedelta(days=1): {"sleep_hours": 3.5},
today - timedelta(days=2): {"sleep_hours": 3.5},
}
assert "sleep_review" in diary_flags(h, today)
h[today - timedelta(days=1)]["sleep_hours"] = 4
assert "sleep_review" not in diary_flags(h, today)
def test_total_threshold_and_zone_are_separate_decisions():
data = load_policy().model_dump()
data.update(cssrs_positive_policy="graded", rus_audit_s_expand_at=1)
data["approval"] = dict(
reviewer="Synthetic fixture",
decision_reference="test",
approved_at="2026-10-03T00:00:00Z",
policy_version=data["version"],
)
policy = IntakePolicy.model_validate(data)
r = score_alcohol({"rus_audit_3": 1, "rus_audit_9": 0, "rus_audit_10": 0}, short=True, sex="m")
assert r.zone == 1 and alcohol_expansion(r, policy)
with pytest.raises(ValueError):
IntakePolicy.model_validate({**data, "rus_audit_s_expand_from_zone": 2})
def test_module_rotation_never_drops_suicide_question():
from app.core.intake_questions import diary_questions
first = {
q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=0, day_index=100)
}
second = {
q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=1, day_index=101)
}
assert "death_thoughts" in first & second
assert "nightmares" in first and "nightmares" not in second
assert "burden" in first and "burden" not in second
+275
View File
@@ -0,0 +1,275 @@
"""Synthetic boundary, branching and safety regression cases for intake (#52)."""
from itertools import product
import pytest
from app.core.questionnaire import (
IntakePolicy,
alcohol_expansion,
evaluate_intake,
extend_alcohol,
load_policy,
score_alcohol,
score_cssrs_recent,
)
from app.core.scales import load_scale
def approved(mode="graded"):
data = load_policy().model_dump()
data.update(cssrs_positive_policy=mode, rus_audit_s_expand_from_zone=2)
data["approval"] = {
"reviewer": "SYNTHETIC TEST ONLY",
"decision_reference": "test-fixture-not-clinical-approval",
"approved_at": "2026-10-03T12:00:00+03:00",
"policy_version": data["version"],
}
return IntakePolicy.model_validate(data)
def zeros(scale):
return {key: 0 for key in load_scale(scale).item_ids}
def intake(**changes):
args = dict(
phq9={"phq9_1": 0, "phq9_2": 0},
tsq={},
pcl5={},
alcohol=zeros("rus_audit_s"),
cssrs={"q1": 0, "q2": 0, "q6_lifetime": 0},
sex="f",
trauma_exposure=False,
policy=approved(),
)
args.update(changes)
return evaluate_intake(**args)
@pytest.mark.parametrize("sex", ["f", "m"])
def test_every_short_alcohol_answer_combination(sex):
# Exhaustively cover all valid patterns, including sparse 0/2/4 history options.
for q3, q9, q10 in product(range(5), (0, 2, 4), (0, 2, 4)):
total = q3 + q9 + q10
expected = (
(1 if total < 2 else 2 if total < 5 else 3 if total == 5 else 4)
if sex == "f"
else (1 if total < 4 else 2 if total < 7 else 3 if total == 7 else 4)
)
result = score_alcohol(
dict(zip(load_scale("rus_audit_s").item_ids, (q3, q9, q10), strict=True)), short=True, sex=sex
)
assert result.complete and result.total == total and result.zone == expected
@pytest.mark.parametrize(
"sex,bounds",
[
("f", [(0, 1), (4, 1), (5, 2), (9, 2), (10, 3), (11, 4), (40, 4)]),
("m", [(0, 1), (8, 1), (9, 2), (13, 2), (14, 3), (16, 3), (17, 4), (40, 4)]),
],
)
def test_full_alcohol_boundaries(sex, bounds):
for total, expected in bounds:
values = zeros("rus_audit")
remaining = total
for item in load_scale("rus_audit").items:
value = max(o.value for o in item.options if o.value <= remaining)
values[item.id] = value
remaining -= value
assert remaining == 0
assert score_alcohol(values, short=False, sex=sex).zone == expected
def test_alcohol_missing_unknown_sex_and_no_implicit_approval():
r = score_alcohol({"rus_audit_3": 0}, short=True, sex="f")
assert not r.complete and r.total is None and r.zone is None
r = score_alcohol(zeros("rus_audit_s"), short=True, sex=None)
assert r.complete and r.total == 0 and r.zone is None
r = score_alcohol({"rus_audit_3": 4, "rus_audit_9": 4, "rus_audit_10": 4}, short=True, sex="m")
assert alcohol_expansion(r, load_policy()) is None
assert alcohol_expansion(r, approved()) is True
def test_full_extension_preserves_history_and_never_counts_twice():
short = {"rus_audit_3": 0, "rus_audit_9": 2, "rus_audit_10": 4}
extra = {"rus_audit_1": 0, **{f"rus_audit_{i}": 0 for i in range(4, 9)}, "rus_audit_9": 2}
merged = extend_alcohol(short, extra)
result = score_alcohol(merged, short=False, sex="f")
assert result.complete and result.total == 6
assert result.derived == ("rus_audit_2",)
assert "rus_audit_2" not in merged # caller-owned raw answers are not mutated
with pytest.raises(ValueError, match="conflicting"):
extend_alcohol(short, {"rus_audit_9": 0})
with pytest.raises(ValueError, match="conflicts"):
score_alcohol({"rus_audit_1": 0, "rus_audit_3": 1}, short=False, sex="m")
@pytest.mark.parametrize("bad", [True, "1", 1.0, -1, 5])
def test_alcohol_rejects_invalid_answers(bad):
with pytest.raises(ValueError):
score_alcohol({"rus_audit_3": bad}, short=True, sex="f")
def test_cannot_use_auditc_items_or_sparse_history_values():
for values in [{"rus_audit_1": 1}, {"auditc_1": 1}, {"rus_audit_9": 1}]:
with pytest.raises(ValueError):
score_alcohol(values, short=True, sex="f")
def test_cssrs_negative_branch_is_complete_without_inventing_zeroes():
a = {"q1": 0, "q2": 0, "q6_lifetime": 0}
r = score_cssrs_recent(a)
assert r.complete and r.risk == "none"
assert r.required == ("q1", "q2", "q6_lifetime")
assert set(a) == set(r.required)
def test_cssrs_remote_behavior_is_not_negative_and_missing_recency_not_low():
a = {"q1": 0, "q2": 0, "q6_lifetime": 1}
r = score_cssrs_recent(a)
assert not r.complete and r.risk is None and r.risk_lower_bound == "moderate"
assert r.missing == ("q6_recent",)
assert score_cssrs_recent({**a, "q6_recent": 0}).risk == "moderate"
assert score_cssrs_recent({**a, "q6_recent": 1}).risk == "high"
def test_cssrs_ideation_branch_and_partial_high():
assert score_cssrs_recent({}).risk is None
assert score_cssrs_recent({"q2": 1}).missing == ("q1", "q3", "q4", "q5", "q6_lifetime")
r = score_cssrs_recent({"q4": 1})
assert not r.complete and r.risk is None and r.risk_lower_bound == "high"
def test_cssrs_exhaustive_complete_ideation_patterns():
for q1, q3, q4, q5, lifetime, recent in product((0, 1), repeat=6):
a = dict(q1=q1, q2=1, q3=q3, q4=q4, q5=q5, q6_lifetime=lifetime)
if lifetime:
a["q6_recent"] = recent
expected = "high" if q4 or q5 or (lifetime and recent) else "moderate" if q3 or lifetime else "low"
r = score_cssrs_recent(a)
assert r.complete and r.risk == expected
def test_cssrs_conflict_preserves_high_signal():
r = score_cssrs_recent({"q1": 0, "q2": 0, "q4": 1, "q6_lifetime": 0, "q6_recent": 1})
assert not r.complete and r.risk_lower_bound == "high" and len(r.conflicts) == 2
@pytest.mark.parametrize("bad", [None, True, "Да", 1.0, 2, -1])
def test_cssrs_requires_explicit_binary_integer(bad):
with pytest.raises(ValueError):
score_cssrs_recent({"q1": bad})
def test_unapproved_policy_never_continues_even_a_negative_screen():
r = intake(policy=load_policy())
assert r.status == "clinical_review_required" and r.primary_route is None
assert intake(policy=load_policy(), cssrs={"q1": 1}).status == "clinical_review_required"
def test_immediate_safety_preempts_unfinished_or_invalid_unrelated_scales():
for overrides in [
dict(help_now=True, cssrs={"broken": "data"}),
dict(phq9={"phq9_9": 1}),
dict(cssrs={"q4": 1}, alcohol={"bad": 50}),
]:
assert intake(policy=load_policy(), **overrides).status == "crisis"
r = intake(phq9={"phq9_9": 1})
assert "phq9_cssrs_discrepancy" in r.flags
def test_physician_must_choose_between_stop_and_graded():
low = {"q1": 1, "q2": 0, "q6_lifetime": 0}
assert intake(cssrs=low, policy=approved("stop_any")).status == "crisis"
r = intake(cssrs=low, policy=approved("graded"))
assert r.status == "ready" and "cssrs_low_safety_plan" in r.flags
data = approved().model_dump()
data["version"] = "changed"
with pytest.raises(ValueError, match="matching version"):
IntakePolicy.model_validate(data)
def test_phq2_and_trauma_expansion_do_not_turn_missing_into_negative():
assert intake().primary_route == "self_help"
r = intake(phq9={"phq9_1": 1, "phq9_2": 1})
assert r.status == "incomplete" and "phq9_9" in r.missing
assert intake(trauma_exposure=None).status == "incomplete"
assert intake(trauma_exposure=True, tsq={"tsq_1": 1}).status == "incomplete"
pc = {f"tsq_{i}": int(i <= 6) for i in range(1, 11)}
assert intake(trauma_exposure=True, tsq=pc).status == "incomplete"
assert intake(trauma_exposure=True, tsq=pc, pcl5=zeros("pcl5")).primary_route == "psychologist"
@pytest.mark.parametrize(
"total,route",
[
(30, "self_help"),
(31, "psychiatrist_or_psychotherapist"),
(49, "psychiatrist_or_psychotherapist"),
(50, "priority_psychiatrist"),
],
)
def test_pdf_pcl_boundaries_are_distinct_from_legacy_33(total, route):
values = zeros("pcl5")
for key in values:
values[key] = min(4, total)
total -= values[key]
assert intake(pcl5=values).primary_route == route
def test_urgent_route_and_parallel_referrals_do_not_overwrite_each_other():
alcohol = {i.id: i.max_value for i in load_scale("rus_audit").items}
r = intake(
cssrs={"q1": 0, "q2": 0, "q6_lifetime": 1, "q6_recent": 0},
alcohol=alcohol,
traumatic_brain_injury=True,
psychotropics=True,
)
assert r.primary_route == "urgent_psychiatrist"
assert r.parallel_routes == ("narcologist", "neurologist")
assert "alcohol_psychotropics" in r.flags
def test_alcohol_expansion_and_unknown_sex_block_routine_route():
assert intake(alcohol={"rus_audit_3": 2, "rus_audit_9": 0, "rus_audit_10": 0}).status == "incomplete"
r = intake(sex=None)
assert r.status == "incomplete" and "alcohol_scoring_sex" in r.missing
def test_non_drinker_does_not_derive_missing_short_answer():
values = zeros("rus_audit")
del values["rus_audit_2"]
del values["rus_audit_3"]
assert intake(alcohol=values).status == "incomplete"
assert "rus_audit_3" in intake(alcohol=values).missing
def test_crisis_keeps_other_flags_and_bad_data_cannot_hide_explicit_signal():
alcohol = {i.id: i.max_value for i in load_scale("rus_audit").items}
r = intake(cssrs={"q4": 1}, pcl5={"pcl5_15": 3}, alcohol=alcohol, traumatic_brain_injury=True, psychotropics=True)
assert r.status == "crisis"
assert {"pcl5_behavior", "alcohol_zone_iv", "alcohol_psychotropics", "traumatic_brain_injury"} <= set(r.flags)
assert intake(cssrs={"q4": 1, "invalid": 7}).status == "crisis"
assert intake(phq9={"phq9_9": 1}, cssrs={"invalid": 7}).status == "crisis"
@pytest.mark.parametrize(
"total,route",
[
(4, "self_help"),
(5, "psychologist"),
(9, "psychologist"),
(10, "psychiatrist_or_psychotherapist"),
(19, "psychiatrist_or_psychotherapist"),
(20, "priority_psychiatrist"),
(24, "priority_psychiatrist"),
],
)
def test_phq_routes_at_each_boundary_without_positive_item9(total, route):
values = zeros("phq9")
for key in list(values)[:8]:
values[key] = min(3, total)
total -= values[key]
assert intake(phq9=values).primary_route == route
+5 -4
View File
@@ -159,9 +159,10 @@ def test_crisis_reply_patient_default() -> None:
assert isinstance(script, CrisisScript) assert isinstance(script, CrisisScript)
assert script.variant == "patient.default" assert script.variant == "patient.default"
assert "112" in script.text assert "112" in script.text
assert "8-800-2000-122" in script.text assert "+7 (495) 989-50-50" in script.text
assert "бесплатно, круглосуточно" in script.text assert "круглосуточно" in script.text
assert "уведомлён" in script.text assert "по тарифу оператора" in script.text
assert "уведомлён" not in script.text
assert script.grounding in script.text assert script.grounding in script.text
assert "психиатр (#8)" in script.status assert "психиатр (#8)" in script.status
@@ -169,7 +170,7 @@ def test_crisis_reply_patient_default() -> None:
def test_crisis_reply_variants() -> None: def test_crisis_reply_variants() -> None:
dv = crisis_reply(["domestic_violence", "suicide"], audience="family") dv = crisis_reply(["domestic_violence", "suicide"], audience="family")
assert dv.variant == "family.domestic_violence" assert dv.variant == "family.domestic_violence"
assert "потерпеть" in dv.text and "112" in dv.text and "8-800-2000-122" in dv.text assert "потерпеть" in dv.text and "112" in dv.text and "+7 (495) 989-50-50" in dv.text
assert crisis_reply(["domestic_violence"]).variant == "patient.domestic_violence" assert crisis_reply(["domestic_violence"]).variant == "patient.domestic_violence"
assert crisis_reply(["harm_others"]).variant == "patient.harm_others" assert crisis_reply(["harm_others"]).variant == "patient.harm_others"
assert crisis_reply(["harm_others"], audience="family").variant == "family.default" assert crisis_reply(["harm_others"], audience="family").variant == "family.default"
+167
View File
@@ -0,0 +1,167 @@
"""Demo scheduler (#13) on the virtual clock: real dialog engine, real monitoring, sqlite."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
import pytest
from sqlalchemy import select
from app.config import Settings
from app.core import clock
from app.core.types import ScrubbedText
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Dose, Prescription
from app.dialog import engine as _engine # noqa: F401 (registers flows in order)
from app.dialog.flows.veteran_daily import load_daily_texts
from app.integrations.base import MemoryOutbox, integrations
from app.scheduler import demo, jobs
TOKEN = "b" * 32
MEM = "sqlite+aiosqlite:///:memory:"
DAY0 = datetime(2026, 10, 5, 8, 0) # visit day (Europe/Moscow)
@pytest.fixture
async def outbox(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[MemoryOutbox]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
box = MemoryOutbox()
monkeypatch.setattr(integrations, "outbox", box)
clock.set_now(DAY0)
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
now = clock.now()
async with session.get_clinical_session() as s:
await repo.upsert_profile(
s,
token=TOKEN,
doctor_id="doc-smirnova",
modules=["veteran"],
therapy_start=now.date(),
birth_year=1992,
sex="m",
)
s.add(Prescription(token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}))
for scale, total in (("pcl5", 46), ("phq9", 14), ("auditc", 3)):
await repo.add_scale_result(
s,
token=TOKEN,
scale=scale,
total=total,
severity="-",
complete=True,
ts=now - timedelta(days=3),
item9=0 if scale == "phq9" else None,
)
await repo.add_message(s, token=TOKEN, direction="in", text=ScrubbedText("..."), ts=now)
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=now)
yield box
clock.reset()
await session.configure()
async def at(day: int, hh: int, mm: int = 0) -> list[demo.Job]:
clock.set_now(DAY0.replace(hour=hh, minute=mm) + timedelta(days=day))
return await demo.run_until(clock.now())
def kinds(delivered: list[demo.Job], *, evaluation: bool = False) -> list[str]:
"""Delivered job kinds; the daily monitoring pass (from 09:00) is left out unless asked for."""
return [j.kind for j in delivered if evaluation or j.kind != "evaluation"]
async def doses() -> list[Dose]:
async with session.get_clinical_session() as s:
return list((await s.scalars(select(Dose).order_by(Dose.planned_at))).all())
async def dialog() -> tuple[str, dict]:
async with session.get_clinical_session() as s:
row = await repo.get_dialog_state(s, TOKEN)
assert row is not None
return row.state, dict(row.data)
async def test_reminder_once_then_missed(outbox: MemoryOutbox) -> None:
assert kinds(await at(1, 8, 5)) == ["med_reminder"]
[dose] = await doses()
assert dose.status == "pending"
sent = outbox.drain(TOKEN)
assert any(b.data == f"med:taken:{dose.id}" for r in sent for row in r.buttons for b in row)
assert kinds(await at(1, 9)) == [] # idempotent: the same reminder is not sent again
assert kinds(await at(1, 10, 6)) == ["missed"]
assert [d.status for d in await doses()] == ["missed"]
assert kinds(await at(1, 11)) == []
async def test_answered_dose_is_not_missed(outbox: MemoryOutbox) -> None:
from app.dialog.contracts import Event, get_engine
await at(1, 8)
[dose] = await doses()
await get_engine().handle(TOKEN, Event(type="button", button=f"med:taken:{dose.id}"))
assert kinds(await at(1, 10, 30)) == []
assert [d.status for d in await doses()] == ["taken"]
async def test_three_missed_days_alert_and_reach_out(outbox: MemoryOutbox) -> None:
for day in (1, 2, 3):
await at(day, 8)
await at(day, 10, 1)
outbox.drain(TOKEN)
async with session.get_clinical_session() as s:
codes = [a.code for a in await repo.list_alerts(s, token=TOKEN)]
assert "missed_doses_7d" in codes
async def test_stale_events_are_not_sent(outbox: MemoryOutbox) -> None:
# The clock jumped straight to the evening: no reminder for 08:00, no dose row, only the check-in.
delivered = await at(1, 20, 5)
assert kinds(delivered) == ["daily_checkin"]
assert await doses() == []
assert outbox.drain(TOKEN)[0].text == load_daily_texts().checkin.start
assert (await dialog())[0].startswith("daily.")
assert kinds(await at(1, 21)) == []
async def test_scales_by_module_schedule(outbox: MemoryOutbox) -> None:
assert "scale_due" not in kinds(await at(7, 10, 5)) # screening on day −3: PCL-5/PHQ-9 every 14 days
delivered = await at(11, 10, 5)
assert [j.params["scale"] for j in delivered if j.kind == "scale_due"] == ["pcl5", "phq9"]
state, data = await dialog()
assert state == "scale.pcl5" and "phq9" in str(data.get("pending_scales"))
async def test_silence_evaluation(outbox: MemoryOutbox) -> None:
# Last message: day 0, 08:00. Day 2 — not yet; day 3, 09:05 — silent for more than 3 days.
assert kinds(await at(2, 9, 5), evaluation=True) == ["med_reminder", "evaluation"]
async with session.get_clinical_session() as s:
assert not await repo.list_alerts(s, token=TOKEN)
outbox.drain(TOKEN)
assert "evaluation" in kinds(await at(3, 9, 5), evaluation=True)
async with session.get_clinical_session() as s:
assert [a.code for a in await repo.list_alerts(s, token=TOKEN)] == ["no_contact_3d"]
# caring contact pushed to the patient (system:reach_out)
assert any("как вы" in r.text.lower() for r in outbox.drain(TOKEN))
async def test_no_contact_without_consent_or_in_crisis(outbox: MemoryOutbox) -> None:
async with session.get_clinical_session() as s:
await repo.set_dialog_state(
s,
token=TOKEN,
state="crisis",
data={"consent": True, "adult": True, "crisis": {"open": True}},
now=clock.now(),
)
assert await at(1, 8) == [] # crisis: no reminder
async with session.get_clinical_session() as s:
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": False}, now=clock.now())
assert await at(1, 8, 30) == []
async def test_jobs_entrypoint_runs_demo_scheduler(outbox: MemoryOutbox) -> None:
clock.set_now(DAY0 + timedelta(days=1))
await jobs.run_until_now()
assert [d.status for d in await doses()] == ["pending"]
assert outbox.drain(TOKEN)
+1
View File
@@ -30,6 +30,7 @@ def severity(scale_id: str, total: int, **kw: object) -> str:
def test_all_scales_load_with_expected_item_counts() -> None: def test_all_scales_load_with_expected_item_counts() -> None:
counts = {k: len(v.items) for k, v in load_all().items()} counts = {k: len(v.items) for k, v in load_all().items()}
expected = {"phq9": 9, "gad7": 7, "pcl5": 20, "auditc": 3, "pcptsd5": 5, "cssrs_screen": 6, "phq4": 4, "dar5": 5} expected = {"phq9": 9, "gad7": 7, "pcl5": 20, "auditc": 3, "pcptsd5": 5, "cssrs_screen": 6, "phq4": 4, "dar5": 5}
expected.update(rus_audit_s=3, rus_audit=10, tsq=10)
assert counts == expected assert counts == expected
+279
View File
@@ -0,0 +1,279 @@
"""MonitoringService (#38): snapshot from the clinical DB → real rules → alerts, colour cache, audit, notify."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from typing import Any
import pytest
from sqlalchemy import select
from app.config import Settings
from app.core import clock
from app.core.types import ScrubbedText
from app.db import repo, session
from app.db.audit import AuditLog
from app.db.bootstrap import init_db
from app.db.clinical import Dose, Prescription
from app.integrations.base import MemoryNotifier, integrations
from app.services import contracts, monitoring
from app.services.snapshot import build_snapshot
TOKEN = "a" * 32
DOCTOR = "doc-smirnova"
MEM = "sqlite+aiosqlite:///:memory:"
DAY0 = datetime(2026, 10, 5, 8, 0)
# Persona (docs/product/01_demo_persona.md): day → (dose taken, mood, sleep, nightmares, alcohol, side effects,
# quit intent). Same table as tests/test_dialog_veteran_days.py, written straight to the DB.
TABLE: dict[int, tuple[bool, int, str, bool, str, list[str], bool]] = {
1: (True, 4, "4-6", True, "none", [], False),
2: (True, 4, "4-6", True, "none", ["nausea"], False),
3: (True, 4, "4-6", True, "none", ["nausea"], False),
4: (True, 3, "4-6", False, "none", ["nausea"], False),
5: (False, 3, "4-6", False, "none", [], True),
6: (True, 4, "6-8", False, "none", [], False),
7: (True, 4, "6-8", False, "none", [], False),
8: (True, 4, "<4", True, "1-2", [], False),
9: (True, 4, "<4", True, "more", [], False),
10: (True, 4, "<4", True, "more", [], False),
11: (True, 3, "4-6", False, "1-2", [], False),
12: (True, 3, "4-6", False, "none", [], False),
13: (True, 2, "<4", True, "none", [], False),
}
COLORS = ["green"] * 3 + ["yellow"] * 2 + ["green"] * 4 + ["yellow"] * 3 + ["red"]
class FailingNotifier:
async def send(self, *args: Any, **kwargs: Any) -> None:
raise ConnectionError("channel down")
@pytest.fixture
async def db(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[MemoryNotifier]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
notifier = MemoryNotifier()
monkeypatch.setattr(integrations, "notifier", notifier)
clock.set_now(DAY0)
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with session.get_clinical_session() as s:
await repo.upsert_profile(
s,
token=TOKEN,
doctor_id=DOCTOR,
modules=["veteran", "depression"],
therapy_start=clock.today(),
birth_year=1992,
sex="m",
)
s.add(Prescription(token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}))
for scale, total in (("pcl5", 46), ("phq9", 14), ("auditc", 3)):
await repo.add_scale_result(
s,
token=TOKEN,
scale=scale,
total=total,
severity="-",
complete=True,
ts=clock.now() - timedelta(days=3),
item9=0 if scale == "phq9" else None,
)
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={}, now=clock.now())
yield notifier
clock.register_persistence(None, None)
clock.reset()
await session.configure()
async def scale(
name: str, total: int, *, item9: int | None = None, severity: str = "-", scheduled: bool = True
) -> None:
async with session.get_clinical_session() as s:
await repo.add_scale_result(
s,
token=TOKEN,
scale=name,
total=total,
severity=severity,
complete=True,
ts=clock.now(),
item9=item9,
scheduled=scheduled,
)
async def color() -> str | None:
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, TOKEN)
assert profile is not None
return profile.status_color
async def ack_all() -> None:
async with session.get_clinical_session() as s:
for a in await repo.list_alerts(s, token=TOKEN, open_only=True):
await repo.ack_alert(s, alert_id=a.id, doctor_id=DOCTOR, ts=clock.now())
await monitoring.refresh_status(s, TOKEN)
async def live_day(day: int) -> None:
"""What the dialog writes on one persona day: the dose, an incoming message and the evening check-in."""
taken, mood, sleep, nightmares, alcohol, effects, quit_intent = TABLE[day]
clock.set_now(DAY0 + timedelta(days=day))
async with session.get_clinical_session() as s:
rx = await s.scalar(select(Prescription))
assert rx is not None
s.add(Dose(token=TOKEN, prescription_id=rx.id, planned_at=clock.now(), status="taken" if taken else "skipped"))
await repo.add_message(s, token=TOKEN, direction="in", text=ScrubbedText("..."), ts=clock.now())
await repo.upsert_checkin(
s,
token=TOKEN,
day=clock.today(),
now=clock.now(),
mood=mood,
med_taken=taken,
sleep_bucket=sleep,
alcohol_bucket=alcohol,
nightmares=nightmares,
side_effects=effects,
quit_intent=quit_intent,
)
async def test_service_is_registered() -> None:
assert isinstance(contracts.get_monitoring(), monitoring.RuleMonitoring)
async def test_andrey_13_days(db: MemoryNotifier) -> None:
mon = monitoring.RuleMonitoring()
colors: list[str | None] = []
returned: dict[int, list[str]] = {}
for day in range(1, 14):
if day == 6:
await ack_all() # the doctor called Andrey after the day-5 alerts
await live_day(day)
if day == 7:
await scale("pcl5", 44)
await scale("phq9", 13, item9=0)
actions = await mon.after_data_change(TOKEN)
if day == 13:
assert [a.code for a in actions] == ["mood_low_3d"] # dialog action: unscheduled PHQ-9
await scale("cssrs_screen", 0, severity="none", scheduled=False)
await scale("phq9", 18, item9=2, scheduled=False)
actions += await mon.after_data_change(TOKEN)
returned[day] = [a.type for a in actions]
colors.append(await color())
assert colors == COLORS
assert "med_info" in returned[5] and "alert" not in returned[5] # alerts are persisted, not returned
assert {"med_info", "suggest_visit"} <= set(returned[10])
assert {"crisis", "show_safety_plan", "schedule_scale"} <= set(returned[13])
async with session.get_clinical_session() as s:
alerts = list(await repo.list_alerts(s, token=TOKEN))
crisis_audit = (await s.scalars(select(AuditLog).where(AuditLog.action == "crisis_acute"))).all()
codes = [a.code for a in reversed(alerts)]
assert codes == [
"side_effect_3d",
"quit_intent",
"sleep_lt4_3n",
"alcohol_3d",
"phq9_item9", # same evaluation: YAML order
"phq9_rise",
]
assert len(crisis_audit) == 1 and crisis_audit[0].token == TOKEN and crisis_audit[0].actor == "system"
acute = [(d, lvl) for d, lvl, _text, _tok in db.sent if lvl == "acute"]
assert acute == [(DOCTOR, "acute"), (monitoring.ON_DUTY_DOCTOR, "acute")]
assert all(tok == TOKEN for *_rest, tok in db.sent)
assert await mon.is_in_crisis(TOKEN)
async def test_running_crisis_is_not_restarted(db: MemoryNotifier) -> None:
mon = monitoring.RuleMonitoring()
await scale("phq9", 18, item9=1)
first = await mon.after_data_change(TOKEN)
assert any(a.type == "crisis" for a in first)
clock.advance(hours=1)
again = await mon.after_data_change(TOKEN)
assert not any(a.code == "phq9_item9" for a in again)
async with session.get_clinical_session() as s:
assert [a.code for a in await repo.list_alerts(s, token=TOKEN, open_only=True) if a.level == "acute"] == [
"phq9_item9"
]
await ack_all()
assert not await mon.is_in_crisis(TOKEN)
assert await color() == "green"
async def test_old_cssrs_high_does_not_restart_crisis(db: MemoryNotifier) -> None:
mon = monitoring.RuleMonitoring()
await scale("cssrs_screen", 4, severity="high", scheduled=False)
assert any(a.type == "crisis" for a in await mon.after_data_change(TOKEN))
await ack_all()
clock.advance(days=2)
assert not any(a.type == "crisis" for a in await mon.after_data_change(TOKEN))
async def test_weapon_access_from_dialog_facts(db: MemoryNotifier) -> None:
async with session.get_clinical_session() as s:
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"facts": {"weapon_access": True}}, now=clock.now()
)
snap = await build_snapshot(s, TOKEN, clock.now())
assert snap is not None and snap.weapon_access is True
await scale("cssrs_screen", 3, severity="moderate", scheduled=False)
actions = await monitoring.RuleMonitoring().after_data_change(TOKEN)
assert {a.code for a in actions if a.type == "crisis"} == {"weapon_access_risk"}
plan = next(a for a in actions if a.type == "show_safety_plan")
assert plan.params["block"] == "means_restriction"
assert await color() == "red"
async def test_snapshot_mapping_and_unknown_patient(db: MemoryNotifier) -> None:
clock.set_now(DAY0 + timedelta(days=4))
await live_day(4)
async with session.get_clinical_session() as s:
snap = await build_snapshot(s, TOKEN, clock.now())
assert await build_snapshot(s, "f" * 32, clock.now()) is None
assert snap is not None
assert (snap.age, snap.sex, snap.therapy_day, snap.modules) == (34, "m", 4, {"veteran", "depression"})
c = snap.checkins[-1]
assert (c.sleep_hours_bucket, c.alcohol, c.side_effects) == ("4-6", "none", ["nausea"])
assert [p.scale for p in snap.scale_results] == ["pcl5", "phq9", "auditc"]
assert snap.last_patient_message_at is not None and snap.last_patient_message_at.date() == clock.today()
assert await monitoring.RuleMonitoring().after_data_change("f" * 32) == []
async def test_young_adult_enhanced_monitoring(db: MemoryNotifier) -> None:
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, birth_year=2004)
actions = await monitoring.RuleMonitoring().after_data_change(TOKEN)
assert not any(a.type == "enhanced_monitoring" for a in actions) # applied by the service itself
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, TOKEN)
assert profile is not None and profile.enhanced_monitoring is True
assert not await repo.list_alerts(s, token=TOKEN)
assert await color() == "green"
async def test_notifier_failure_keeps_alert(db: MemoryNotifier, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(integrations, "notifier", FailingNotifier())
await scale("pcl5", 55)
await monitoring.RuleMonitoring().after_data_change(TOKEN)
async with session.get_clinical_session() as s:
assert [(a.code, a.level) for a in await repo.list_alerts(s, token=TOKEN)] == [("pcl5_severe", "high")]
assert await color() == "yellow"
async def test_clock_offset_persists(db: MemoryNotifier) -> None:
clock.reset()
await monitoring.init_clock_persistence()
clock.advance(days=3)
await monitoring.flush_clock_writes()
async with session.get_clinical_session() as s:
assert await repo.load_clock_offset(s) == pytest.approx(timedelta(days=3).total_seconds())
# "Restart": in-memory offset lost, restored from clock_state.
clock.register_persistence(None, None)
clock.reset()
await monitoring.init_clock_persistence()
assert clock.offset() == timedelta(days=3)