release: dev → main (сдача хакатона) #106
@@ -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 app.api import fixtures
|
||||
from app.api.questionnaire import router as questionnaire_router
|
||||
from app.api.schemas import (
|
||||
AckIn,
|
||||
AlertOut,
|
||||
@@ -26,6 +27,7 @@ from app.dialog.contracts import get_engine
|
||||
from app.integrations.base import MemoryOutbox, integrations
|
||||
|
||||
router = APIRouter(prefix="/api/v1")
|
||||
router.include_router(questionnaire_router)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ doctor dashboard
|
||||
|
||||
@@ -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)
|
||||
+14
-11
@@ -41,9 +41,10 @@ class IntakePolicy(BaseModel):
|
||||
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)
|
||||
pcptsd5_expand_at: int = Field(ge=1, le=5, 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)
|
||||
@@ -56,9 +57,11 @@ class IntakePolicy(BaseModel):
|
||||
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
|
||||
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:
|
||||
@@ -94,7 +97,7 @@ def score_alcohol(answers: dict[str, int], *, short: bool, sex: Sex | None) -> A
|
||||
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 not in values:
|
||||
if item == "rus_audit_2" and item not in values:
|
||||
values[item] = 0
|
||||
derived.append(item)
|
||||
result = score(scale_id, values, sex=sex)
|
||||
@@ -121,6 +124,8 @@ def alcohol_expansion(result: AlcoholResult, policy: IntakePolicy) -> bool | Non
|
||||
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
|
||||
|
||||
@@ -203,7 +208,7 @@ def _additional_flags(pcl5, alcohol, sex, traumatic_brain_injury, psychotropics,
|
||||
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:
|
||||
if psychotropics and (any(v > 0 for v in alcohol.values())):
|
||||
flags.append("alcohol_psychotropics")
|
||||
if full.zone == 4:
|
||||
flags.append("alcohol_zone_iv")
|
||||
@@ -217,7 +222,7 @@ def _additional_flags(pcl5, alcohol, sex, traumatic_brain_injury, psychotropics,
|
||||
def evaluate_intake(
|
||||
*,
|
||||
phq9: dict[str, int],
|
||||
pcptsd5: dict[str, int],
|
||||
tsq: dict[str, int],
|
||||
pcl5: dict[str, int],
|
||||
alcohol: dict[str, int],
|
||||
cssrs: dict[str, int],
|
||||
@@ -269,12 +274,10 @@ def evaluate_intake(
|
||||
return IntakeDecision(policy.version, "clinical_review_required", None, (), tuple(flags), c.missing)
|
||||
|
||||
phq = score("phq9", phq9)
|
||||
pc = score("pcptsd5", pcptsd5)
|
||||
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}
|
||||
if "rus_audit_3" in full_alcohol.derived:
|
||||
short_values["rus_audit_3"] = 0
|
||||
short_alcohol = score_alcohol(short_values, short=True, sex=sex)
|
||||
missing = list(c.missing)
|
||||
phq2_items = ("phq9_1", "phq9_2")
|
||||
@@ -285,11 +288,11 @@ def evaluate_intake(
|
||||
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 any(pcptsd5.values()):
|
||||
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.pcptsd5_expand_at
|
||||
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("pcptsd5").item_ids if k not in pcptsd5)
|
||||
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.
|
||||
|
||||
@@ -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 intake as _intake # noqa: F401
|
||||
from app.db.clinical import ClinicalBase
|
||||
from app.db.identity import IdentityBase
|
||||
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)
|
||||
@@ -4,7 +4,7 @@ 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 "я рядом, врач уже уведомлён" + emergency resources instead.
|
||||
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 —
|
||||
|
||||
+52
-1
@@ -22,15 +22,18 @@ Pipeline for one incoming event (SPEC §3, guardrails #1, #2, #3):
|
||||
Logs carry only the token, states and event codes (guardrail #8).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
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
|
||||
@@ -45,12 +48,14 @@ 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
|
||||
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)
|
||||
|
||||
@@ -76,8 +81,13 @@ class Engine:
|
||||
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:
|
||||
return await self._handle(token, event, session, texts)
|
||||
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)
|
||||
@@ -117,6 +127,13 @@ class Engine:
|
||||
session, token=token, direction="out", text=scrub(reply.text), ts=ctx.now, fsm_state=ctx.state
|
||||
)
|
||||
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,
|
||||
@@ -143,6 +160,12 @@ class Engine:
|
||||
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)
|
||||
@@ -153,6 +176,14 @@ class Engine:
|
||||
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
|
||||
@@ -181,11 +212,29 @@ class Engine:
|
||||
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):
|
||||
@@ -252,6 +301,8 @@ class Engine:
|
||||
|
||||
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()
|
||||
|
||||
@@ -42,6 +42,20 @@ def menu_reply(ctx: Ctx, text: str | None = None) -> Reply:
|
||||
[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)
|
||||
|
||||
|
||||
@@ -53,6 +67,10 @@ async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str:
|
||||
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:
|
||||
@@ -275,6 +293,8 @@ async def _reach_out(ctx: Ctx, event: Event) -> Step:
|
||||
|
||||
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
|
||||
@@ -311,7 +331,11 @@ async def revoke(ctx: Ctx) -> Step:
|
||||
"""
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
+170
-7
@@ -1,13 +1,176 @@
|
||||
"""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 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 questionnaire as service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
_worker = None
|
||||
_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def start() -> None:
|
||||
"""Start periodic ticking (#13)."""
|
||||
async def plan():
|
||||
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:
|
||||
"""Stop the scheduler (#13)."""
|
||||
async def deliver():
|
||||
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:
|
||||
"""Replay all due events up to `app.core.clock.now()` in order, idempotently (#13)."""
|
||||
async def run_until_now():
|
||||
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
|
||||
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,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,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
|
||||
@@ -1,10 +1,10 @@
|
||||
# Review candidate from the supplied MVP PDF, not a signed clinical protocol.
|
||||
version: mvp-review-2026-10-03
|
||||
version: mvp-v2-review-2026-10-03
|
||||
approval: null
|
||||
cssrs_positive_policy: pending
|
||||
rus_audit_s_expand_from_zone: null
|
||||
phq2_expand_at: 2
|
||||
pcptsd5_expand_at: 3
|
||||
tsq_expand_at: 6
|
||||
phq9_psychologist_at: 5
|
||||
phq9_specialist_at: 10
|
||||
phq9_priority_at: 20
|
||||
|
||||
@@ -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"
|
||||
note: "если есть угроза жизни прямо сейчас"
|
||||
helpline:
|
||||
name: "Телефон доверия"
|
||||
phone: "8-800-2000-122"
|
||||
note: "бесплатно, круглосуточно"
|
||||
name: "Психологическая помощь МЧС России"
|
||||
phone: "+7 (495) 989-50-50"
|
||||
note: "круглосуточно; стоимость звонка по тарифу оператора"
|
||||
police:
|
||||
name: "Полиция"
|
||||
phone: "102"
|
||||
@@ -30,7 +30,7 @@ variants:
|
||||
себе вред (оружие, таблетки), по возможности уберите это подальше или отдайте близкому человеку
|
||||
и побудьте рядом с кем-то, кому доверяете.
|
||||
resources: [emergency, helpline]
|
||||
doctor_notified: "Ваш врач уже уведомлён и свяжется с вами."
|
||||
doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
|
||||
grounding: "Сейчас попробуйте сделать медленный вдох на 4 счёта и выдох на 6 — несколько раз. Я рядом."
|
||||
|
||||
patient.harm_others:
|
||||
@@ -39,7 +39,7 @@ variants:
|
||||
Пожалуйста, отойдите от человека и от всего, чем можно причинить вред, и побудьте там, где спокойнее.
|
||||
Если кому-то угрожает опасность прямо сейчас — позвоните 112.
|
||||
resources: [emergency, helpline]
|
||||
doctor_notified: "Ваш врач уже уведомлён и свяжется с вами."
|
||||
doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
|
||||
grounding: "Поставьте ступни на пол, сделайте медленный вдох на 4 счёта и выдох на 6 — несколько раз."
|
||||
|
||||
patient.domestic_violence:
|
||||
@@ -48,7 +48,7 @@ variants:
|
||||
Если вам или детям угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112.
|
||||
Кризисные службы и телефон доверия помогут найти безопасное место и поддержку.
|
||||
resources: [emergency, police, helpline]
|
||||
doctor_notified: "Ваш врач уже уведомлён."
|
||||
doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
|
||||
grounding: "Если вы сейчас в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6. Я рядом."
|
||||
|
||||
family.default:
|
||||
@@ -58,7 +58,7 @@ variants:
|
||||
спокойно уберите из доступа оружие, таблетки и острые предметы. Не спорьте и не стыдите —
|
||||
просто будьте рядом и слушайте.
|
||||
resources: [emergency, helpline]
|
||||
doctor_notified: "Лечащий врач уже уведомлён."
|
||||
doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
|
||||
grounding: "Позаботьтесь и о себе: сделайте медленный вдох на 4 счёта и выдох на 6."
|
||||
|
||||
family.domestic_violence:
|
||||
@@ -68,5 +68,5 @@ variants:
|
||||
Если угрожает опасность — уйдите в безопасное место (к соседям, родным) и позвоните 112.
|
||||
Кризисные службы помогут с временным убежищем и поддержкой.
|
||||
resources: [emergency, police, helpline]
|
||||
doctor_notified: "Лечащий врач уже уведомлён."
|
||||
doctor_notified: "Не ждите ответа врача, если помощь нужна сейчас: звоните 112."
|
||||
grounding: "Когда будете в безопасности — сделайте медленный вдох на 4 счёта и выдох на 6."
|
||||
|
||||
@@ -75,9 +75,9 @@ revoked:
|
||||
|
||||
crisis:
|
||||
# Повторный острый сигнал в уже открытом кризисе: скрипт второй раз не шлём, только это + ресурсы.
|
||||
repeat: "Я рядом. Ваш врач уже уведомлён. Если вам угрожает опасность прямо сейчас — позвоните:"
|
||||
repeat: "Я рядом. Ваш при угрозе жизни звоните 112. Если вам угрожает опасность прямо сейчас — позвоните:"
|
||||
# Любое другое сообщение, пока кризис открыт (до подтверждения врача).
|
||||
hold: "Я на связи и читаю вас. Ваш врач в курсе и свяжется с вами. Если станет хуже — звоните 112 или на телефон доверия 8-800-2000-122."
|
||||
hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия +7 (495) 989-50-50."
|
||||
# Врач подтвердил кризисный алерт (doctor_ack): выходим из кризиса.
|
||||
closed: "Ваш врач получил сообщение и в курсе вашей ситуации. Спасибо, что остаётесь на связи. Если снова станет тяжело — напишите мне в любой момент."
|
||||
|
||||
@@ -97,7 +97,7 @@ safety_plan:
|
||||
- "Что помогает мне самому: прогулка, душ, дыхание, любимое дело."
|
||||
- "Люди и места, которые отвлекают: к кому можно пойти."
|
||||
- "Кому позвонить, если тяжело: близкий человек, сослуживец."
|
||||
- "Специалисты: ваш врач, телефон доверия 8-800-2000-122, экстренные службы 112."
|
||||
- "Специалисты: ваш врач, телефон доверия +7 (495) 989-50-50, экстренные службы 112."
|
||||
- "Безопасная среда: оружие и лишние лекарства — на хранение близкому человеку."
|
||||
means_restriction: "Если дома есть оружие или запас лекарств — попросите близкого человека временно хранить их у себя."
|
||||
|
||||
@@ -111,4 +111,4 @@ visit:
|
||||
|
||||
unavailable: |-
|
||||
Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже.
|
||||
Если вам плохо прямо сейчас — звоните 112 или на телефон доверия 8-800-2000-122.
|
||||
Если вам плохо прямо сейчас — звоните 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,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 не выполняет ни один из этих пунктов.
|
||||
+12
-9
@@ -10,8 +10,10 @@
|
||||
Получена дополненная версия от 3 октября. [Разбор изменений](revision-2026-10-03-v2/REVIEW.md)
|
||||
и [рабочий обратный перевод](revision-2026-10-03-v2/backtranslation-draft.md) сохранены
|
||||
отдельно. TSQ заменил симптомные вопросы PC-PTSD-5; добавлены дневник и расписания.
|
||||
**Код пока следует предыдущей версии, новая спецификация ещё не реализована.**
|
||||
Текст TSQ в новом PDF отсутствует: перечислены только темы.
|
||||
**Новая спецификация подключена к диалогу за закрытым переключателем.**
|
||||
[Состав реализации, ограничения и порядок проверки](IMPLEMENTATION.md).
|
||||
Текст TSQ получен отдельно из приложения Г1 клинических рекомендаций 2023 года;
|
||||
актуальность этой редакции ещё нужно подтвердить.
|
||||
Независимая проверка перевода остаётся pending.
|
||||
Описания реализованных правил и ссылки на страницы ниже относятся к исходному
|
||||
14-страничному PDF, если явно не указана новая версия.
|
||||
@@ -21,9 +23,9 @@
|
||||
`app/core/questionnaire.py` содержит детерминированные правила первичного опроса:
|
||||
короткий PHQ-2 из первых двух ответов PHQ-9, фильтр травмы PC-PTSD-5,
|
||||
условное дополнение до полных шкал, RUS-AUDIT-S, структуру C-SSRS Recent,
|
||||
приоритет основного маршрута и параллельные направления. Это новый модуль для
|
||||
интеграции после проверки, а не уже подключённый сценарий бота. Существующие
|
||||
контракты, `config/modules.yaml`, мониторинг и исторические результаты не менялись.
|
||||
приоритет основного маршрута и параллельные направления. Правила подключены к версионированному диалогу, дневнику и планировщику.
|
||||
Замороженные контракты, `config/modules.yaml` и исторические результаты не менялись.
|
||||
Клиническое включение заблокировано до подписей.
|
||||
|
||||
`config/questionnaire.yaml` фиксирует **кандидатные** пороги из PDF. Политика без
|
||||
записи врача не выдаёт обычный маршрут. Положительный PHQ-9/9, известный высокий
|
||||
@@ -58,7 +60,7 @@ DOI 10.17116/profmed20222508121. Рисунок визуально провер
|
||||
При дополнении до полной шкалы остаются пункты 1,2,4,5,6,7,8. Ответы на 3,9,10
|
||||
переиспользуются по исходным ID и не суммируются дважды. Записывать версию и факт
|
||||
получения ответа в краткой форме: её пояснение травмы отличается от полного текста.
|
||||
Правило PDF `п.1 = 0` может вывести нули только для 2/3, **не для 9/10**;
|
||||
Правило PDF `п.1 = 0` в новой версии выводит ноль только для 2; исходный 3 сохраняется, как и 9/10;
|
||||
выведенные ответы возвращаются отдельно в `derived`, исходный словарь не меняется.
|
||||
|
||||
Полная форма и её зоны взяты из пользовательского PDF с.7–9, транскрипция является
|
||||
@@ -152,9 +154,9 @@ q6 за всю жизнь всем; при положительном q6 уто
|
||||
| ID | Решение, которое нужно зафиксировать | Статус |
|
||||
|---|---|---|
|
||||
| C01 | Любой «Да» останавливает обычный опрос или разрешено продолжение после низкого риска? Различить обычный опрос, уточнение риска и кризисную помощь. Новый PDF с.1 против с.12; в старом с.1 против с.11. Скриншот поддерживает остановку. | pending |
|
||||
| C02 | С какой зоны RUS-AUDIT-S дополнять полную форму? Не переносить порог AUDIT-C ≥3/≥4. | 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 для популяции. Код пока использует PC-PTSD-5. | 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 |
|
||||
@@ -170,7 +172,8 @@ q6 за всю жизнь всем; при положительном q6 уто
|
||||
|
||||
## Подключение к диалогу
|
||||
|
||||
Адаптер вызывает `evaluate_intake` после каждого ответа и на ручной кнопке помощи.
|
||||
Адаптер проверяет непосредственные сигналы после каждого ответа и на кнопке помощи;
|
||||
при завершении вызывает `evaluate_intake` для общего маршрута.
|
||||
`crisis` открывает фиксированную кризисную ветку до LLM; `clinical_review_required`
|
||||
не разрешает автоматическое продолжение; `incomplete` запрашивает обязательные
|
||||
пропуски; `ready` содержит основной маршрут и отдельные параллельные направления.
|
||||
|
||||
@@ -10,11 +10,20 @@
|
||||
| Московская служба психологической помощи | 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` номер 8-800-2000-122 ошибочно подписан
|
||||
просто «Телефон доверия» для всех пациентов. Взрослой ветке нужен ресурс для взрослых;
|
||||
детский оставить с точной аудиторией. Замена номера и текстов в активном диалоге
|
||||
должна быть согласована с владельцем кризисного сценария и врачом (#8).
|
||||
Этот пакет проверок сам по себе активный кризисный экран не меняет.
|
||||
В `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` проводить без совершения вызова. Для прочих
|
||||
|
||||
@@ -26,21 +26,18 @@
|
||||
"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-review-2026-10-03",
|
||||
"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": false,
|
||||
"pending_changes": [
|
||||
"tsq_replaces_pcptsd5",
|
||||
"alcohol_q1_zero_derives_q2_only",
|
||||
"daily_diary",
|
||||
"medication_followup_schedule"
|
||||
],
|
||||
"tsq_verbatim_source_verified": false,
|
||||
"current_guideline_edition_verified": false
|
||||
"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,
|
||||
@@ -48,5 +45,9 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,22 +5,25 @@
|
||||
в `source-manifest.json`. Все страницы новой версии прочитаны и просмотрены.
|
||||
Это разбор спецификации и языкового материала, не клиническое согласование.
|
||||
|
||||
Технический этап выполнен после разбора: [реализация и ограничения](../IMPLEMENTATION.md).
|
||||
Ниже сохранены замечания к источнику с обновлённым статусом реализации.
|
||||
|
||||
## Что изменилось и как это влияет на реализацию
|
||||
|
||||
| Раздел новой версии | Изменение | Состояние реализации / дальнейшее действие |
|
||||
|---|---|---|
|
||||
| с.1, 3–4, 13 | TSQ вместо пяти симптомных вопросов PC-PTSD-5: 10 ответов, последняя неделя, реакция не реже двух раз, расширение при ≥6 | Текущий `evaluate_intake` ещё использует PC-PTSD-5 и порог 3. Нужны новая версия шкалы и миграция правил; переименования недостаточно. |
|
||||
| с.1, 3–4, 13 | TSQ вместо пяти симптомных вопросов PC-PTSD-5: 10 ответов, последняя неделя, реакция не реже двух раз, расширение при ≥6 | Реализован TSQ из приложения Г1 редакции 2023 года: 10 пунктов, порог 6; прежние результаты не переименованы. |
|
||||
| с.3 | Вступление и фильтр травмы PC-PTSD-5 сохранены; при «Нет» TSQ не задаётся | Записывать «не проводился по фильтру», не десять нулевых ответов. Вопрос о травме не является одиннадцатым пунктом TSQ. |
|
||||
| с.4 | Темы TSQ прямо обозначены как ориентиры, а не готовые вопросы | Нельзя показывать таблицу как официальный опросник. Требуется дословная инструкция и пункты из приложения Г1 с проверенной редакцией. |
|
||||
| с.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 и п.3 по старому источнику. В новом потоке п.3 уже задан на первом этапе: сохранить исходный ответ, противоречие уточнить. Изменение кода ещё не внесено. |
|
||||
| с.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 | Ежедневный дневник: ядро, модули ПТСР, депрессии и суицидальных мыслей; тренды и внеплановые опросы | Это новые авторские вопросы и правила, не валидированная сокращённая шкала. В текущий код не добавлены. |
|
||||
| с.13–14 | Контроль после начала/смены лекарств на 7-й, 14-й, 28-й день, затем каждые 4 недели; при пересечении расписаний — более частое | Расписания и объединение реализованы, вопрос о переносимости добавлен как кандидатный. Требуются согласование и корректные даты изменений назначений. |
|
||||
| с.14–16 | Ежедневный дневник: ядро, модули ПТСР, депрессии и суицидальных мыслей; тренды и внеплановые опросы | Это новые авторские вопросы и правила, не валидированная сокращённая шкала. Добавлены за закрытым переключателем; определения и якоря ждут согласования. |
|
||||
| с.16–17 | Добавлена таблица соответствия КР ПТСР ID 753 | Утверждения PDF об актуальной редакции, силе рекомендаций и применимости требуют проверки по первоисточнику; ссылка PDF ведёт на вторичный правовой сайт. |
|
||||
| с.18–19 | Добавлены кризисные телефоны и аудитории | Это список кандидатов, а не результат проверки доступности. Предыдущая проверка официальных сайтов охватывала 112, МЧС, московскую и детскую линии; новые три позиции отдельно не подтверждены. |
|
||||
| с.18–19 | Добавлены кризисные телефоны и аудитории | Это список кандидатов, а не результат проверки доступности. Проверка источников дополнена в ../crisis-contacts.md; проверка соединения не выполнена. |
|
||||
|
||||
## Противоречия и недостающие определения
|
||||
|
||||
@@ -88,12 +91,10 @@ PC-PTSD-5 и его отдельная инструкция о месяце.
|
||||
|
||||
## Что остаётся после этого разбора
|
||||
|
||||
Текущая реализация соответствует предыдущей спецификации и **не готова к запуску
|
||||
по новой**. Следующий технический этап: получить точный русский TSQ, заменить
|
||||
кандидатный PTSD-скрининг с отдельной версией, учесть изменение пропуска п.2
|
||||
RUS-AUDIT и добавить тесты. Дневник и расписания требуют отдельных задач по
|
||||
слоям приложения и формализации перечисленных условий. Врачебные решения,
|
||||
независимая проверка перевода и операционная проверка линий не выполнены.
|
||||
Техническая реализация новой версии подготовлена в PR #53. Полное описание —
|
||||
[IMPLEMENTATION.md](../IMPLEMENTATION.md). Клиническое включение остаётся закрытым:
|
||||
врачебные решения, независимая проверка перевода и операционная проверка линий
|
||||
не выполнены; отдельная русская форма Since Last Contact не получена.
|
||||
|
||||
Попытка проверить актуальную карточку [КР 753_1](https://cr.minzdrav.gov.ru/preview-cr/753_1)
|
||||
через веб-инструмент не дала доступного содержимого; актуальность редакции
|
||||
|
||||
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."
|
||||
}
|
||||
@@ -67,6 +67,9 @@ async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None:
|
||||
"safety_plans",
|
||||
"clock_state",
|
||||
"audit_log",
|
||||
"questionnaire_records",
|
||||
"questionnaire_notices",
|
||||
"questionnaire_tasks",
|
||||
} == clinical_tables
|
||||
await init_db() # idempotent
|
||||
|
||||
|
||||
@@ -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,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
|
||||
@@ -35,7 +35,7 @@ def zeros(scale):
|
||||
def intake(**changes):
|
||||
args = dict(
|
||||
phq9={"phq9_1": 0, "phq9_2": 0},
|
||||
pcptsd5={},
|
||||
tsq={},
|
||||
pcl5={},
|
||||
alcohol=zeros("rus_audit_s"),
|
||||
cssrs={"q1": 0, "q2": 0, "q6_lifetime": 0},
|
||||
@@ -196,10 +196,10 @@ def test_phq2_and_trauma_expansion_do_not_turn_missing_into_negative():
|
||||
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, pcptsd5={"pcptsd5_1": 1}).status == "incomplete"
|
||||
pc = {f"pcptsd5_{i}": int(i <= 3) for i in range(1, 6)}
|
||||
assert intake(trauma_exposure=True, pcptsd5=pc).status == "incomplete"
|
||||
assert intake(trauma_exposure=True, pcptsd5=pc, pcl5=zeros("pcl5")).primary_route == "psychologist"
|
||||
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(
|
||||
@@ -238,11 +238,12 @@ def test_alcohol_expansion_and_unknown_sex_block_routine_route():
|
||||
assert r.status == "incomplete" and "alcohol_scoring_sex" in r.missing
|
||||
|
||||
|
||||
def test_full_non_drinker_derivation_also_satisfies_short_stage():
|
||||
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 == "ready"
|
||||
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():
|
||||
|
||||
@@ -159,9 +159,10 @@ def test_crisis_reply_patient_default() -> None:
|
||||
assert isinstance(script, CrisisScript)
|
||||
assert script.variant == "patient.default"
|
||||
assert "112" in script.text
|
||||
assert "8-800-2000-122" in script.text
|
||||
assert "бесплатно, круглосуточно" in script.text
|
||||
assert "уведомлён" in script.text
|
||||
assert "+7 (495) 989-50-50" in script.text
|
||||
assert "круглосуточно" in script.text
|
||||
assert "по тарифу оператора" in script.text
|
||||
assert "уведомлён" not in script.text
|
||||
assert script.grounding in script.text
|
||||
assert "психиатр (#8)" in script.status
|
||||
|
||||
@@ -169,7 +170,7 @@ def test_crisis_reply_patient_default() -> None:
|
||||
def test_crisis_reply_variants() -> None:
|
||||
dv = crisis_reply(["domestic_violence", "suicide"], audience="family")
|
||||
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(["harm_others"]).variant == "patient.harm_others"
|
||||
assert crisis_reply(["harm_others"], audience="family").variant == "family.default"
|
||||
|
||||
@@ -30,7 +30,7 @@ def severity(scale_id: str, total: int, **kw: object) -> str:
|
||||
def test_all_scales_load_with_expected_item_counts() -> None:
|
||||
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.update(rus_audit_s=3, rus_audit=10)
|
||||
expected.update(rus_audit_s=3, rus_audit=10, tsq=10)
|
||||
assert counts == expected
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user