Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c0836a1be | ||
|
|
20973bef17 | ||
|
|
d06720fdab | ||
|
|
2b7cbb8c2f | ||
|
|
2972b330b0 | ||
|
|
2b13d54ed7 | ||
|
|
4c6e150162 | ||
|
|
de5b1e05fb | ||
|
|
0a92266bab | ||
|
|
0bd07045cd | ||
|
|
c42ef621e5 | ||
|
|
8b61eecf1b | ||
|
|
fe5b6fe103 | ||
|
|
57b5ad1eb2 | ||
|
|
da089d67cb | ||
|
|
3cd7e2fd75 | ||
|
|
f6e4828f01 | ||
|
|
e884d896ff | ||
|
|
fa40be3d94 |
@@ -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}
|
||||||
@@ -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,
|
||||||
@@ -26,6 +27,7 @@ from app.dialog.contracts import get_engine
|
|||||||
from app.integrations.base import MemoryOutbox, integrations
|
from app.integrations.base import MemoryOutbox, integrations
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/v1")
|
router = APIRouter(prefix="/api/v1")
|
||||||
|
router.include_router(questionnaire_router)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ doctor dashboard
|
# ------------------------------------------------------------------ doctor dashboard
|
||||||
|
|||||||
@@ -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")))
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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), ())
|
||||||
@@ -4,6 +4,7 @@ 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.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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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])
|
||||||
@@ -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)
|
||||||
@@ -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}")
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")))
|
||||||
+176
-7
@@ -1,13 +1,182 @@
|
|||||||
"""Scheduler on the (virtual) demo clock — stub until #13. Called from app.main lifespan."""
|
"""Questionnaire jobs on the demo clock; clinical release disabled by default.
|
||||||
|
|
||||||
|
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.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():
|
||||||
|
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(30)
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
||||||
|
_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
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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: Случалось ли с Вами подобное событие?
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"}
|
||||||
@@ -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"}
|
||||||
@@ -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 года; условия использования и актуальную редакцию подтвердить до включения."
|
||||||
@@ -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."
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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: "Обсудите с врачом, как сократить употребление алкоголя и составить безопасный план."
|
||||||
@@ -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: "Если станет хуже — напишите мне, я передам врачу."
|
||||||
@@ -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: "Последние дни настроение ниже обычного. Давайте я задам несколько вопросов — так ваш врач лучше поймёт, как вы сейчас."
|
||||||
@@ -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: |-
|
||||||
|
Помощник не выдаёт заключений и не отправляет ваши ответы ни в ГИБДД, ни в Росгвардию, ни на медкомиссии. Допуск к оружию и вождению решает отдельная медицинская комиссия по своим правилам, установленным законом.
|
||||||
|
Как именно это связано с вашим лечением — спросите лечащего врача прямо. Это нормальный вопрос, его задают многие, и честный ответ лучше, чем откладывать помощь из-за неизвестности.
|
||||||
@@ -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.
@@ -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 дополнительно восстанавливают полный текст страницы из структуры,
|
||||||
|
проверяют хеши и сверяют все комбинации ветвей с цветовыми категориями бланка.
|
||||||
@@ -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" "Крайне сильно" "" "" "" ""
|
||||||
|
@@ -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.
@@ -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" "Крайне сильно" "" "" "" ""
|
||||||
|
@@ -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"
|
||||||
|
Binary file not shown.
@@ -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"
|
||||||
|
@@ -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"
|
||||||
|
Binary file not shown.
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -67,6 +67,9 @@ 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",
|
||||||
} == clinical_tables
|
} == clinical_tables
|
||||||
await init_db() # idempotent
|
await init_db() # idempotent
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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}»"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user