Compare commits

..
Author SHA1 Message Date
Pavel Eliseev 0bd07045cd docs(clinical): attach visual PR implementation report
CI / ruff · pytest (pull_request) Successful in 56s
Refs #52
2026-10-03 18:33:57 +03:00
Pavel Eliseev c42ef621e5 feat(questionnaire): integrate revised intake and follow-up monitoring
CI / ruff · pytest (pull_request) Successful in 55s
Add gated TSQ, Russian C-SSRS Recent, durable responses and notifications, diaries, schedules and audited clinical review. Preserve manual assessment when the Russian follow-up source is unavailable.

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

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

Refs #10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 15:42:36 +03:00
57 changed files with 6116 additions and 19 deletions
+98
View File
@@ -0,0 +1,98 @@
"""Audited questionnaire review for the synthetic prototype.
Like the existing API, doctor_id is supplied by the caller; production must put
these routes behind authenticated staff identity. No clinical data cross into identity.
"""
from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from app.core import clock
from app.db import repo
from app.db.audit import audit
from app.db.intake import QuestionnaireRecord
from app.db.session import get_clinical_session
from app.services import questionnaire as service
router = APIRouter(tags=["questionnaire-review"])
class ReviewIn(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
doctor_id: str = Field(min_length=1)
assessment_performed: Literal[True]
decision: Literal["resume", "keep_hold"]
reference: str = Field(min_length=1, max_length=200, pattern=r"^[A-Za-z0-9_./:#-]+$")
async def require_doctor(session, token, doctor_id):
profile = await repo.get_profile(session, token)
if profile is None or profile.doctor_id != doctor_id:
raise HTTPException(404, "patient not found")
@router.get("/patients/{token}/questionnaires")
async def history(token: str, doctor_id: str):
async with get_clinical_session() as session:
await require_doctor(session, token, doctor_id)
rows = await service.records(session, token)
await audit(session, actor=doctor_id, action="view_questionnaires", token=token, ts=clock.now())
return [
{
"id": r.id,
"kind": r.kind,
"status": r.status,
"version": r.version,
"policy_version": r.policy_version,
"started_at": r.started_at,
"completed_at": r.completed_at,
"answers": r.answers,
"details": r.details,
}
for r in rows
]
@router.post("/patients/{token}/questionnaires/{record_id}/review")
async def review(token: str, record_id: str, body: ReviewIn):
now = clock.now()
async with get_clinical_session() as session:
await service.lock_patient(session, token)
await require_doctor(session, token, body.doctor_id)
state = await repo.get_dialog_state(session, token)
row = await session.get(QuestionnaireRecord, record_id)
if row is None or row.token != token:
raise HTTPException(404, "record not found")
if not state or state.data.get("consent") is not True:
raise HTTPException(409, "consent required")
if row.status not in ("review_required", "interrupted"):
raise HTTPException(409, "record does not require review")
if body.decision == "resume" and (state.data.get("crisis") or {}).get("open"):
raise HTTPException(409, "close the acute episode after assessment before resuming routine work")
detail = {
"doctor_id": body.doctor_id,
"decision": body.decision,
"reference": body.reference,
"at": now.isoformat(),
}
row.details = {**row.details, "clinician_review": detail}
if body.decision == "resume":
row.status = "reviewed"
remaining = [
r
for r in await service.records(session, token)
if r.id != row.id and r.status in ("review_required", "interrupted")
]
if not remaining:
state.data = {**state.data, "intake_review_required": False, "questionnaire_record": None}
await audit(
session,
actor=body.doctor_id,
action="review_questionnaire",
token=token,
details={"record_id": row.id, "decision": body.decision, "reference": body.reference},
ts=now,
)
return {"id": row.id, "status": row.status, "review": detail}
+2
View File
@@ -7,6 +7,7 @@ Lane B replaces bodies in #12 without changing paths or schemas (those are the f
from fastapi import APIRouter, HTTPException
from 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
+59
View File
@@ -0,0 +1,59 @@
"""Russian Recent source transcription, separate from the legacy sequential scale.
This content does not authorize administering or continuing the questionnaire.
Use questionnaire.score_cssrs_recent for required/missing items and risk, and
evaluate_intake for the clinical policy gate before presenting another question.
Never render interviewer guidance as if it were another patient question.
"""
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings
ItemId = Literal["q1", "q2", "q3", "q4", "q5", "q6_lifetime", "q6_recent"]
class SourceModel(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
class CssrsOption(SourceModel):
value: int = Field(strict=True, ge=0, le=1)
label: str
class CssrsItem(SourceModel):
id: ItemId
text: str = Field(min_length=1)
interviewer_guidance: str | None
recall_period: Literal["past_month", "lifetime", "past_3_months"]
yes_risk: Literal["low", "moderate", "high"]
class CssrsRussianForm(SourceModel):
id: Literal["cssrs_recent_ru"]
version: str = Field(min_length=1)
title: str
subtitle: Literal["Screen Version - Recent"]
period_label_original: str
initial_instruction: str
branch_instruction: str
options: tuple[CssrsOption, CssrsOption]
items: tuple[CssrsItem, ...]
@model_validator(mode="after")
def _structure(self) -> "CssrsRussianForm":
if tuple(item.id for item in self.items) != ("q1", "q2", "q3", "q4", "q5", "q6_lifetime", "q6_recent"):
raise ValueError("Recent form requires seven ordered answer fields")
if tuple(option.value for option in self.options) != (1, 0):
raise ValueError("source answer columns are Yes, No")
return self
def load_cssrs_russian(path: Path | None = None) -> CssrsRussianForm:
path = path or get_settings().config_dir / "instruments" / "cssrs_recent_ru.yaml"
return CssrsRussianForm.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
+141
View File
@@ -0,0 +1,141 @@
"""Question selection and exact versioned content, with no side effects."""
from dataclasses import dataclass
import yaml
from app.config import get_settings
from app.core.cssrs_content import load_cssrs_russian
from app.core.questionnaire import IntakePolicy, alcohol_expansion, score_alcohol, score_cssrs_recent
from app.core.scales import ScaleDef, load_scale
@dataclass(frozen=True)
class Question:
id: str
text: str
options: tuple[tuple[object, str], ...] = ()
period: str = "context"
numeric: bool = False
optional: bool = False
def content() -> dict:
return yaml.safe_load((get_settings().config_dir / "instruments/intake_forms.yaml").read_text())
def texts() -> dict:
return yaml.safe_load((get_settings().config_dir / "texts/intake.yaml").read_text())
def scale_question(scale_id: str, item_id: str) -> Question:
forms = content()["forms"]
scale = ScaleDef.model_validate(forms[scale_id]) if scale_id in forms else load_scale(scale_id)
item = next(i for i in scale.items if i.id == item_id)
instruction = scale.instruction or ""
# The beverage table is part of the approved questionnaire source.
if item_id == "rus_audit_2":
instruction += "\n" + texts()["standard_drinks"]
return Question(
item_id,
instruction + "\n\n" + item.text,
tuple((o.value, o.label) for o in item.options),
scale.recall_period or "",
)
def group_answers(answers: dict, prefix: str) -> dict[str, int]:
return {k: v for k, v in answers.items() if k.startswith(prefix + "_")}
def cssrs_answers(answers: dict) -> dict[str, int]:
return {k.removeprefix("cssrs_"): v for k, v in answers.items() if k.startswith("cssrs_")}
def next_intake(answers: dict, policy: IntakePolicy, sex: str | None) -> Question | None:
c = score_cssrs_recent(cssrs_answers(answers))
if c.missing:
form = load_cssrs_russian()
item = next(i for i in form.items if i.id == c.missing[0])
period = texts()["periods"][item.recall_period]
# Guidance is interviewer-facing; retained in the source and record version.
return Question(
"cssrs_" + item.id,
period + "\n\n" + item.text,
tuple((o.value, o.label) for o in form.options),
item.recall_period,
)
for item in ("phq9_1", "phq9_2"):
if item not in answers:
return scale_question("phq9", item)
if "trauma" not in answers:
return Question(
"trauma", content()["trauma_introduction"] + "\n\n" + content()["trauma_filter"], ((1, "Да"), (0, "Нет"))
)
if answers["trauma"]:
for item in load_scale("tsq").item_ids:
if item not in answers:
return scale_question("tsq", item)
if "recent_trauma" not in answers:
return Question("recent_trauma", texts()["recent_trauma"], ((1, "Да"), (0, "Нет")))
for item in load_scale("rus_audit_s").item_ids:
if item not in answers:
return scale_question("rus_audit_s", item)
if sex is None and "sex" not in answers:
return Question("sex", texts()["sex"], (("f", "Женский"), ("m", "Мужской"), ("unknown", "Не указывать")))
for key in ("tbi", "psychotropics"):
if key not in answers:
return Question(key, texts()[key], ((1, "Да"), (0, "Нет")))
if answers["phq9_1"] + answers["phq9_2"] >= policy.phq2_expand_at:
for item in load_scale("phq9").item_ids:
if item not in answers:
return scale_question("phq9", item)
if answers["trauma"] and sum(group_answers(answers, "tsq").values()) >= policy.tsq_expand_at:
for item in load_scale("pcl5").item_ids:
if item not in answers:
return scale_question("pcl5", item)
alcohol = group_answers(answers, "rus_audit")
selected_sex = sex or (answers.get("sex") if answers.get("sex") in ("m", "f") else None)
short = score_alcohol(
{k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids}, short=True, sex=selected_sex
)
if alcohol_expansion(short, policy):
for item in load_scale("rus_audit").item_ids:
if item == "rus_audit_2" and alcohol.get("rus_audit_1") == 0:
continue
if item not in answers:
return scale_question("rus_audit", item)
return None
def diary_questions(
modules: list[str], *, suicide_monitoring: bool, weekday: int, day_index: int | None = None
) -> tuple[Question, ...]:
config = yaml.safe_load((get_settings().config_dir / "instruments/diary.yaml").read_text())
selected = ["core"]
detailed = [name for name in ("veteran", "depression") if name in modules]
if len(detailed) == 2:
detailed = [detailed[(day_index if day_index is not None else weekday) % 2]]
if "veteran" in detailed:
selected.append("ptsd")
if "depression" in detailed:
selected.append("depression")
if suicide_monitoring or "depression" in modules:
selected.append("suicide")
questions = []
for section in selected:
for item in config[section]:
if item.get("weekly") and weekday != 0:
continue
questions.append(
Question(
item["id"],
item["text"],
tuple((o["value"], o["label"]) for o in item.get("options", [])),
"past_week" if item.get("weekly") else "past_day",
item.get("numeric", False),
item.get("optional", False),
)
)
questions.sort(key=lambda q: q.id != "death_thoughts")
return tuple(questions)
+153
View File
@@ -0,0 +1,153 @@
"""Versioned release gate and deterministic follow-up planning."""
from calendar import monthrange
from datetime import date, timedelta
from pathlib import Path
from typing import Literal
from zoneinfo import ZoneInfo
import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings
from app.core.questionnaire import Approval, load_policy
class RuntimePolicy(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
version: str
enabled: bool = False
approval: Approval | None = None
content_review: Approval | None = None
contact_review: Approval | None = None
timezone: str
daily_hour: int = Field(ge=0, le=23)
mood_baseline_days: int = Field(ge=2)
mood_bad_days: int = Field(ge=1)
mood_drop: int = Field(ge=1, le=10)
sleep_bad_days: int = Field(ge=1)
sleep_hours_below: float = Field(gt=0, le=24)
nightmares_at: int = Field(ge=0, le=4)
missed_medication_days: int = Field(ge=1)
high_risk_silence_days: int = Field(ge=1)
medication_days: tuple[int, ...]
medication_repeat_days: int = Field(ge=1)
route_intervals: dict[str, dict[str, int]]
alcohol_repeat_days: int = Field(ge=1)
phq9_retriage_delta: int = Field(ge=1)
pcl5_retriage_delta: int = Field(ge=1)
@model_validator(mode="after")
def valid(self):
ZoneInfo(self.timezone)
if set(self.route_intervals) != {"after_crisis", "specialist", "self_help"} or any(
set(row) != {"phq9", "pcl5"} for row in self.route_intervals.values()
):
raise ValueError("all route and scale intervals must be defined")
if not self.medication_days or tuple(sorted(set(self.medication_days))) != self.medication_days:
raise ValueError("medication days must be nonempty and strictly increasing")
if self.medication_days[0] < 1 or any(v < 1 for row in self.route_intervals.values() for v in row.values()):
raise ValueError("intervals must be positive")
if any(
a and a.policy_version != self.version for a in (self.approval, self.content_review, self.contact_review)
):
raise ValueError("review must match the runtime version")
return self
@property
def released(self) -> bool:
return self.enabled and all((self.approval, self.content_review, self.contact_review))
def load_runtime(path: Path | None = None) -> RuntimePolicy:
path = path or get_settings().config_dir / "intake_runtime.yaml"
return RuntimePolicy.model_validate(yaml.safe_load(path.read_text()))
def intake_enabled() -> bool:
return load_runtime().released and load_policy().approval is not None
def month_before(day: date, months: int = 1) -> date:
year, month = divmod(day.year * 12 + day.month - 1 - months, 12)
return day.replace(year=year, month=month + 1, day=min(day.day, monthrange(year, month + 1)[1]))
def due_scales(
*,
today: date,
enrolled: date,
last: dict[str, date],
route: Literal["specialist", "self_help"],
medication_changed: date | None = None,
after_crisis: date | None = None,
policy: RuntimePolicy | None = None,
) -> tuple[str, ...]:
"""Return each overdue form once. Therapy day zero is the recorded start/change date.
Due dates stay due until completion. Overlapping schedules choose the earlier
date; missed ticks do not create a burst of historical questionnaires.
"""
p = policy or load_runtime()
due = set()
for scale in ("phq9", "pcl5"):
completed = last.get(scale)
dates = [(completed or enrolled) + timedelta(days=p.route_intervals[route][scale])]
if after_crisis and after_crisis <= today:
dates.append(
max(completed or after_crisis, after_crisis) + timedelta(days=p.route_intervals["after_crisis"][scale])
)
if medication_changed and medication_changed <= today:
for offset in p.medication_days:
milestone = medication_changed + timedelta(days=offset)
if completed is None or completed < milestone:
dates.append(milestone)
break
else:
dates.append(
max(completed or medication_changed, medication_changed + timedelta(days=p.medication_days[-1]))
+ timedelta(days=p.medication_repeat_days)
)
if min(dates) <= today:
due.add(scale)
if (last.get("rus_audit_s") or enrolled) + timedelta(days=p.alcohol_repeat_days) <= today:
due.add("rus_audit_s")
return tuple(sorted(due))
def diary_flags(history: dict[date, dict], today: date, policy: RuntimePolicy | None = None) -> tuple[str, ...]:
"""Consecutive calendar dates; missing data never become zero or missed doses.
Baseline is the seven dates BEFORE the three-day deterioration window;
every baseline value is required. Nightmares OR short sleep must hold on
each of three nights. These candidate definitions require signed review.
"""
p = policy or load_runtime()
flags = []
current = history.get(today, {})
if current.get("death_thoughts") == 1:
flags.append("suicide_assessment_now")
recent = [history.get(today - timedelta(days=i), {}) for i in range(p.mood_bad_days)]
baseline = [
history.get(today - timedelta(days=i), {}).get("mood")
for i in range(p.mood_bad_days, p.mood_bad_days + p.mood_baseline_days)
]
if all(x is not None for x in baseline) and all(x.get("mood") is not None for x in recent):
mean = sum(baseline) / len(baseline)
if all(mean - x["mood"] >= p.mood_drop for x in recent):
flags.append("phq9_due_mood")
nights = [history.get(today - timedelta(days=i), {}) for i in range(p.sleep_bad_days)]
if all(
(r.get("nightmares") is not None and r["nightmares"] >= p.nightmares_at)
or (r.get("sleep_hours") is not None and r["sleep_hours"] < p.sleep_hours_below)
for r in nights
):
flags.append("sleep_review")
doses = [history.get(today - timedelta(days=i), {}) for i in range(p.missed_medication_days)]
if all(r.get("medication") == "none" for r in doses):
flags.append("medication_contact")
if current.get("side_effects") == 1:
flags.append("side_effects_review")
if current.get("medication") == "partial":
flags.append("partial_medication_review")
return tuple(flags)
+337
View File
@@ -0,0 +1,337 @@
"""Versioned intake rules for clinical review (#52).
No patient wording or LLM calls. This module deliberately does not replace legacy
cssrs_screen (whose q6 only covers three months) or reinterpret historical data.
The dialog adapter must pass RAW answers, persist the policy version, and respect
the returned status. Pending clinical policy never authorizes routine continuation.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Literal
import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.config import get_settings
from app.core.scales import Sex, load_scale, score
Risk = Literal["none", "low", "moderate", "high"]
class Approval(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, str_strip_whitespace=True)
reviewer: str = Field(min_length=1)
decision_reference: str = Field(min_length=1)
approved_at: datetime
policy_version: str = Field(min_length=1)
@model_validator(mode="after")
def _aware(self) -> Approval:
if self.approved_at.tzinfo is None:
raise ValueError("approval timestamp must include timezone")
return self
class IntakePolicy(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
version: str = Field(min_length=1)
approval: Approval | None = None
cssrs_positive_policy: Literal["pending", "stop_any", "graded"] = "pending"
rus_audit_s_expand_at: int | None = Field(default=None, ge=1, le=12, strict=True)
rus_audit_s_expand_from_zone: int | None = Field(default=None, ge=1, le=4, strict=True)
phq2_expand_at: int = Field(ge=1, le=6, strict=True)
tsq_expand_at: int = Field(ge=1, le=10, strict=True)
phq9_psychologist_at: int = Field(ge=0, le=27, strict=True)
phq9_specialist_at: int = Field(ge=0, le=27, strict=True)
phq9_priority_at: int = Field(ge=0, le=27, strict=True)
pcl5_specialist_at: int = Field(ge=0, le=80, strict=True)
pcl5_priority_at: int = Field(ge=0, le=80, strict=True)
pcl5_behavior_flag_at: int = Field(ge=0, le=4, strict=True)
@model_validator(mode="after")
def _approval_matches(self) -> IntakePolicy:
if self.approval and (
self.approval.policy_version != self.version
or self.cssrs_positive_policy == "pending"
or (self.rus_audit_s_expand_from_zone is None and self.rus_audit_s_expand_at is None)
):
raise ValueError("approval requires the matching version and resolved clinical decisions")
if self.rus_audit_s_expand_from_zone is not None and self.rus_audit_s_expand_at is not None:
raise ValueError("choose a total threshold OR a zone for alcohol expansion")
if not self.phq9_psychologist_at < self.phq9_specialist_at < self.phq9_priority_at:
raise ValueError("PHQ-9 thresholds must be ordered")
if self.pcl5_specialist_at >= self.pcl5_priority_at:
raise ValueError("PCL-5 thresholds must be ordered")
return self
def load_policy(path: Path | None = None) -> IntakePolicy:
path = path or get_settings().config_dir / "questionnaire.yaml"
return IntakePolicy.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
@dataclass(frozen=True)
class AlcoholResult:
scale: str
complete: bool
total: int | None
zone: int | None
missing: tuple[str, ...]
derived: tuple[str, ...] = ()
def score_alcohol(answers: dict[str, int], *, short: bool, sex: Sex | None) -> AlcoholResult:
"""RUS-AUDIT-S uses original items 3/9/10; unknown sex never borrows a cut-off."""
if sex not in (None, "m", "f"):
raise ValueError("sex must be m, f or None")
scale_id = "rus_audit_s" if short else "rus_audit"
# Validate BEFORE derivation: bools, floats and conflicting answers must not be hidden.
score(scale_id, answers, sex=sex)
values = dict(answers)
derived = []
if not short and values.get("rus_audit_1") == 0:
for item in ("rus_audit_2", "rus_audit_3"):
if values.get(item, 0) != 0:
raise ValueError("never drinks conflicts with positive consumption answers")
if item == "rus_audit_2" and item not in values:
values[item] = 0
derived.append(item)
result = score(scale_id, values, sex=sex)
missing = tuple(i for i in load_scale(scale_id).item_ids if i not in values)
zone = int(result.severity_code[-1]) if result.complete and sex is not None else None
return AlcoholResult(
scale_id, result.complete, result.total if result.complete else None, zone, missing, tuple(derived)
)
def extend_alcohol(short_answers: dict[str, int], additional: dict[str, int]) -> dict[str, int]:
"""Reuse original item IDs. Duplicate equal answers count once; conflicting ones fail."""
score("rus_audit_s", short_answers)
score("rus_audit", additional)
for key in short_answers.keys() & additional.keys():
if short_answers[key] != additional[key]:
raise ValueError(f"conflicting repeated answer: {key}")
return {**short_answers, **additional}
def alcohol_expansion(result: AlcoholResult, policy: IntakePolicy) -> bool | None:
"""None means unresolved, never a negative screen or permission to skip the full form."""
if result.scale != "rus_audit_s":
raise ValueError("expansion requires RUS-AUDIT-S")
if not policy.approval or not result.complete or result.zone is None:
return None
if policy.rus_audit_s_expand_at is not None:
return result.total >= policy.rus_audit_s_expand_at
assert policy.rus_audit_s_expand_from_zone is not None
return result.zone >= policy.rus_audit_s_expand_from_zone
_CSSRS_ITEMS = ("q1", "q2", "q3", "q4", "q5", "q6_lifetime", "q6_recent")
@dataclass(frozen=True)
class CssrsResult:
complete: bool
risk: Risk | None
risk_lower_bound: Risk
positive: bool
required: tuple[str, ...]
missing: tuple[str, ...]
conflicts: tuple[str, ...]
def score_cssrs_recent(answers: dict[str, int]) -> CssrsResult:
"""Recent screener structure; q1-5 month, q6 lifetime + conditional three-month follow-up.
These keys describe data, NOT an official Russian translation. Positive signals
survive missing/contradictory answers; only complete consistent data yield final risk.
"""
for key, value in answers.items():
if key not in _CSSRS_ITEMS or type(value) is not int or value not in (0, 1):
raise ValueError("C-SSRS requires known item IDs and integer 0/1 answers")
required = ["q1", "q2"]
if answers.get("q2") == 1:
required.extend(["q3", "q4", "q5"])
required.append("q6_lifetime")
if answers.get("q6_lifetime") == 1:
required.append("q6_recent")
conflicts = []
if answers.get("q2") == 0 and any(answers.get(k) == 1 for k in ("q3", "q4", "q5")):
conflicts.append("ideation_branch_conflict")
if answers.get("q6_lifetime") == 0 and answers.get("q6_recent") == 1:
conflicts.append("behavior_recency_conflict")
missing = tuple(k for k in required if k not in answers)
complete = not missing and not conflicts
lower: Risk = "none"
if any(answers.get(k) == 1 for k in ("q4", "q5", "q6_recent")):
lower = "high"
elif answers.get("q3") == 1 or answers.get("q6_lifetime") == 1:
lower = "moderate"
elif any(answers.get(k) == 1 for k in ("q1", "q2")):
lower = "low"
return CssrsResult(
complete, lower if complete else None, lower, 1 in answers.values(), tuple(required), missing, tuple(conflicts)
)
@dataclass(frozen=True)
class IntakeDecision:
policy_version: str
status: Literal["crisis", "clinical_review_required", "incomplete", "ready"]
primary_route: str | None
parallel_routes: tuple[str, ...]
flags: tuple[str, ...]
missing: tuple[str, ...]
def _additional_flags(pcl5, alcohol, sex, traumatic_brain_injury, psychotropics, policy):
"""Preserve independently observable flags even when the primary route is crisis.
Invalid unrelated data is marked for review rather than blocking a known crisis.
The routine branch still validates every answer strictly before routing.
"""
flags = []
try:
score("pcl5", pcl5)
if any(pcl5.get(k, 0) >= policy.pcl5_behavior_flag_at for k in ("pcl5_15", "pcl5_16")):
flags.append("pcl5_behavior")
except ValueError:
flags.append("invalid_pcl5_answers")
try:
full = score_alcohol(alcohol, short=False, sex=sex)
short_values = {k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids}
short = score_alcohol(short_values, short=True, sex=sex)
zone = full.zone if full.complete else short.zone
if zone is not None and zone >= 2:
flags.append("alcohol_zone_ii_or_higher")
if psychotropics and (any(v > 0 for v in alcohol.values())):
flags.append("alcohol_psychotropics")
if full.zone == 4:
flags.append("alcohol_zone_iv")
except ValueError:
flags.append("invalid_alcohol_answers")
if traumatic_brain_injury:
flags.append("traumatic_brain_injury")
return flags
def evaluate_intake(
*,
phq9: dict[str, int],
tsq: dict[str, int],
pcl5: dict[str, int],
alcohol: dict[str, int],
cssrs: dict[str, int],
sex: Sex | None,
trauma_exposure: bool | None,
help_now: bool = False,
traumatic_brain_injury: bool = False,
psychotropics: bool = False,
policy: IntakePolicy | None = None,
) -> IntakeDecision:
"""Two-stage candidate routing. No side effects, notifications, or claims of approval.
`alcohol` keeps full-scale IDs across both stages. Existing PHQ/PCL wording and
new C-SSRS content still need release review even with a signed routing policy.
"""
policy = policy or load_policy()
if type(help_now) is not bool or type(traumatic_brain_injury) is not bool or type(psychotropics) is not bool:
raise ValueError("context flags must be bool")
if trauma_exposure is not None and type(trauma_exposure) is not bool:
raise ValueError("trauma_exposure must be bool or None")
# Independent flags survive the higher-priority crisis route.
flags = _additional_flags(pcl5, alcohol, sex, traumatic_brain_injury, psychotropics, policy)
# A manual emergency request does not depend on the completeness of any scale.
if help_now:
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(["help_now", *flags]), ())
# Act on explicit safety answers BEFORE processing unrelated, possibly invalid scales.
phq_item9 = phq9.get("phq9_9")
item9_positive = type(phq_item9) is int and phq_item9 in (1, 2, 3)
try:
c = score_cssrs_recent(cssrs)
except ValueError:
known_high = any(type(cssrs.get(k)) is int and cssrs[k] == 1 for k in ("q4", "q5", "q6_recent"))
if not item9_positive and not known_high:
raise
flags.extend(["invalid_cssrs_answers", "phq9_item9_positive" if item9_positive else "cssrs_positive"])
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), ())
if item9_positive:
flags.append("phq9_item9_positive")
if c.complete and c.risk == "none":
flags.append("phq9_cssrs_discrepancy")
if c.positive:
flags.append("cssrs_positive")
flags.extend(c.conflicts)
if item9_positive or c.risk_lower_bound == "high":
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
if c.positive and policy.approval and policy.cssrs_positive_policy == "stop_any":
return IntakeDecision(policy.version, "crisis", "crisis", (), tuple(flags), c.missing)
if c.positive and not policy.approval:
return IntakeDecision(policy.version, "clinical_review_required", None, (), tuple(flags), c.missing)
phq = score("phq9", phq9)
pc = score("tsq", tsq)
pcl = score("pcl5", pcl5)
full_alcohol = score_alcohol(alcohol, short=False, sex=sex)
short_values = {k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids}
short_alcohol = score_alcohol(short_values, short=True, sex=sex)
missing = list(c.missing)
phq2_items = ("phq9_1", "phq9_2")
missing.extend(k for k in phq2_items if k not in phq9)
phq2_complete = all(k in phq9 for k in phq2_items)
phq2_positive = phq2_complete and sum(phq9[k] for k in phq2_items) >= policy.phq2_expand_at
if phq2_positive:
missing.extend(k for k in load_scale("phq9").item_ids if k not in phq9)
if trauma_exposure is None:
missing.append("trauma_exposure")
if trauma_exposure is False and tsq:
flags.append("trauma_filter_conflict")
pc_positive = trauma_exposure is True and pc.complete and pc.total >= policy.tsq_expand_at
if trauma_exposure is True and not pc.complete:
missing.extend(k for k in load_scale("tsq").item_ids if k not in tsq)
if pc_positive and not pcl.complete:
missing.extend(k for k in load_scale("pcl5").item_ids if k not in pcl5)
# Partially administered optional scales also cannot be treated as completed negatives.
if len(phq9) > 2 and not phq.complete:
missing.extend(k for k in load_scale("phq9").item_ids if k not in phq9)
if pcl5 and not pcl.complete:
missing.extend(k for k in load_scale("pcl5").item_ids if k not in pcl5)
missing.extend(short_alcohol.missing)
if sex is None:
missing.append("alcohol_scoring_sex")
expand = alcohol_expansion(short_alcohol, policy)
if expand or (len(alcohol) > 3 and not full_alcohol.complete):
missing.extend(full_alcohol.missing)
if expand is None:
flags.append("alcohol_transition_unresolved")
if not policy.approval or c.conflicts or "trauma_filter_conflict" in flags:
return IntakeDecision(
policy.version, "clinical_review_required", None, (), tuple(flags), tuple(dict.fromkeys(missing))
)
if missing:
return IntakeDecision(policy.version, "incomplete", None, (), tuple(flags), tuple(dict.fromkeys(missing)))
# PHQ-2-negative does not invent a PHQ-9 total; it simply does not trigger expansion.
phq_total = phq.total if phq.complete else 0
pcl_total = pcl.total if pcl.complete else 0
if c.risk == "moderate":
primary = "urgent_psychiatrist"
elif phq_total >= policy.phq9_priority_at or pcl_total >= policy.pcl5_priority_at:
primary = "priority_psychiatrist"
elif phq_total >= policy.phq9_specialist_at or pcl_total >= policy.pcl5_specialist_at:
primary = "psychiatrist_or_psychotherapist"
elif phq_total >= policy.phq9_psychologist_at or pc_positive:
primary = "psychologist"
else:
primary = "self_help"
parallel = []
if full_alcohol.zone == 4:
parallel.append("narcologist")
if traumatic_brain_injury:
parallel.append("neurologist")
if c.risk == "low":
flags.append("cssrs_low_safety_plan")
return IntakeDecision(policy.version, "ready", primary, tuple(parallel), tuple(flags), ())
+1
View File
@@ -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
+55
View File
@@ -0,0 +1,55 @@
"""Versioned questionnaire records, without modifying historical scale results."""
from datetime import date, datetime
from typing import Any
from sqlalchemy import Date, ForeignKey, Integer, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.clinical import BigId, ClinicalBase
from app.db.types import JSONType, TZDateTime
class QuestionnaireRecord(ClinicalBase):
__tablename__ = "questionnaire_records"
id: Mapped[str] = mapped_column(String(32), primary_key=True)
token: Mapped[str] = mapped_column(String(32), index=True)
kind: Mapped[str] = mapped_column(String(32))
version: Mapped[str] = mapped_column(String(64))
policy_version: Mapped[str] = mapped_column(String(64))
started_at: Mapped[datetime] = mapped_column(TZDateTime)
completed_at: Mapped[datetime | None] = mapped_column(TZDateTime)
day: Mapped[date] = mapped_column(Date)
status: Mapped[str] = mapped_column(String(32))
answers: Mapped[dict[str, Any]] = mapped_column(JSONType, default=dict)
# Per-field time, recall period, source and derived values; totals may be null.
details: Mapped[dict[str, Any]] = mapped_column(JSONType, default=dict)
class QuestionnaireNotice(ClinicalBase):
__tablename__ = "questionnaire_notices"
__table_args__ = (UniqueConstraint("token", "event_key"),)
id: Mapped[int] = mapped_column(BigId, primary_key=True, autoincrement=True)
token: Mapped[str] = mapped_column(String(32), index=True)
event_key: Mapped[str] = mapped_column(String(128))
alert_id: Mapped[int] = mapped_column(ForeignKey("alerts.id"))
created_at: Mapped[datetime] = mapped_column(TZDateTime)
attempted_at: Mapped[datetime | None] = mapped_column(TZDateTime)
accepted_at: Mapped[datetime | None] = mapped_column(TZDateTime)
attempts: Mapped[int] = mapped_column(Integer, default=0)
status: Mapped[str] = mapped_column(String(32), default="pending")
error_code: Mapped[str | None] = mapped_column(String(80))
class QuestionnaireTask(ClinicalBase):
__tablename__ = "questionnaire_tasks"
__table_args__ = (UniqueConstraint("token", "task_key"),)
id: Mapped[int] = mapped_column(BigId, primary_key=True, autoincrement=True)
token: Mapped[str] = mapped_column(String(32), index=True)
task_key: Mapped[str] = mapped_column(String(128))
kind: Mapped[str] = mapped_column(String(32))
due_at: Mapped[datetime] = mapped_column(TZDateTime)
status: Mapped[str] = mapped_column(String(32), default="pending")
record_id: Mapped[str | None] = mapped_column(String(32))
delivery: Mapped[list] = mapped_column(JSONType, default=list)
sent_at: Mapped[datetime | None] = mapped_column(TZDateTime)
+73
View File
@@ -0,0 +1,73 @@
"""Execution of *dialog* actions returned by `MonitoringService.after_data_change()` (ADR-007).
The rules (lane B, `config/rules.yaml`) decide WHAT happens; this module only turns an already chosen action
into replies. `alert` / `enhanced_monitoring` are handled by lane B itself (persisted + doctor notified), so they
are ignored here. While a crisis is open only crisis-relevant actions (crisis, safety plan, risk screen) reach
the patient: no medication tips or visit offers in the middle of a crisis.
"""
from collections.abc import Iterable
from typing import Any
from app.core.rules import Action
from app.db.clinical import SafetyPlan
from app.dialog.contracts import Button, Reply
from app.dialog.crisis import crisis_open, open_crisis
from app.dialog.fsm import Ctx, Step
from app.dialog.scale_session import active_scale, add_pending
# med_info topic → phrase intent of `config/texts/phrases.yaml` (the LLM only rephrases the template).
_MED_INFO_INTENTS = {"delayed_effect": "quit_intent", "alcohol_interaction": "alcohol_info", "nausea": "nausea_info"}
_CRISIS_SAFE = {"crisis", "show_safety_plan", "schedule_scale"}
def _plan_line(step: Any) -> str:
if isinstance(step, dict):
return ": ".join(str(v) for v in step.values() if v)
return str(step)
async def safety_plan_reply(ctx: Ctx, block: str | None = None) -> Reply:
"""The plan composed with the doctor (`safety_plans`), or the Stanley–Brown template from config."""
row = await ctx.session.get(SafetyPlan, ctx.token)
steps = [_plan_line(s) for s in row.steps] if row is not None and row.steps else ctx.texts.safety_plan.template
lines = [ctx.texts.safety_plan.title, *(f"{i}. {s}" for i, s in enumerate(steps, 1))]
if block == "means_restriction":
lines += ["", ctx.texts.safety_plan.means_restriction]
return Reply(text="\n".join(lines), kind="safety_plan")
async def execute(ctx: Ctx, actions: Iterable[Action]) -> Step:
step = Step()
shown_plan = False
for action in actions:
if crisis_open({**ctx.data, **step.data}) and action.type not in _CRISIS_SAFE:
continue
if action.type == "crisis":
if not crisis_open({**ctx.data, **step.data}):
step = step.then(open_crisis(ctx, categories=[], source=f"rule:{action.code}"))
elif action.type == "show_safety_plan" and not shown_plan:
shown_plan = True
step = step.then(Step(replies=[await safety_plan_reply(ctx, action.params.get("block"))]))
elif action.type == "schedule_scale" and action.params.get("scale"):
scale_id = str(action.params["scale"])
if active_scale(step.next_state or ctx.state) == scale_id:
continue # already being asked
data = {**ctx.data, **step.data}
pending = add_pending(data, scale_id, bool(action.params.get("scheduled", False)))
step = step.then(Step(data={"pending_scales": pending}))
elif action.type == "med_info":
intent = _MED_INFO_INTENTS.get(str(action.params.get("topic")), "default")
text = await ctx.gateway.phrase(intent, {})
step = step.then(Step(replies=[Reply(text=text, kind="info")]))
elif action.type == "suggest_visit":
text = await ctx.gateway.phrase("book_visit_offer", {})
button = Button(label=ctx.texts.visit.button, data="menu:visit")
step = step.then(Step(replies=[Reply(text=text, buttons=[[button]], kind="info")]))
elif action.type == "reach_out":
topic = str(action.params.get("topic", "default"))
texts = ctx.texts.reach_out
text = {"silence": texts.silence, "missed_doses": texts.missed_doses}.get(topic, texts.default)
step = step.then(Step(replies=[Reply(text=text)]))
# alert / enhanced_monitoring: lane B persists and notifies; nothing to say to the patient.
return step
+49
View File
@@ -0,0 +1,49 @@
"""Crisis path of the dialog (guardrail #2): fixed texts only, never generated by the LLM.
Decisions:
* The crisis script (`app.core.safety.crisis_reply`, `config/texts/crisis.yaml`) is sent verbatim, once per
open crisis. "Open" = `data["crisis"]["open"]` — set when the crisis starts, cleared only by the doctor's
acknowledgement (`doctor_ack`), so a patient repeating the phrase does not get the same long script again:
they get a short fixed support message and emergency resources instead.
* Entering the crisis drops unfinished scale sessions (their answered items are already stored item by item):
nothing else is asked while the patient is in crisis, except the C-SSRS screen.
* Without valid consent (before consent, after refusal/revocation, minors) we still answer a crisis message —
protecting life is the one processing ground that does not need consent (152-FZ art. 10 p. 2.3) — but with
a variant WITHOUT "your doctor has been notified", because no doctor is notified, and nothing is stored.
"""
from typing import Any
from app.core.safety import CrisisResource, CrisisScript, crisis_reply
from app.dialog.contracts import Reply
from app.dialog.fsm import Ctx, Step
def crisis_open(data: dict[str, Any]) -> bool:
return bool((data.get("crisis") or {}).get("open"))
def _resource_lines(resources: list[CrisisResource]) -> list[str]:
return [f"• {r.name}: {r.phone}" + (f" ({r.note})" if r.note else "") for r in resources]
def open_crisis(ctx: Ctx, categories: list[str], source: str) -> Step:
"""Start the crisis (full script) or, if one is already open, send the short repeat text.
`source` is a code for the dashboard/audit trail: "safety:lexicon", "safety:llm", "scale:phq9", "rule:<id>".
"""
script = crisis_reply(categories)
if crisis_open(ctx.data):
text = "\n".join([ctx.texts.crisis.repeat, *_resource_lines(script.resources)])
return Step(replies=[Reply(text=text, kind="crisis")])
crisis = {"open": True, "opened_at": ctx.now.isoformat(), "source": source, "variant": script.variant}
return Step(
replies=[Reply(text=script.text, kind="crisis")],
next_state="crisis",
data={"crisis": crisis, "scales": None},
)
def no_consent_crisis_text(script: CrisisScript) -> str:
"""Crisis script without the "doctor notified" line — for people we may not (or did not) notify about."""
return "\n".join([script.body, "", *_resource_lines(script.resources), "", script.grounding])
+359
View File
@@ -0,0 +1,359 @@
"""Dialog engine (issue #10, ADR-004): `Engine.handle(token, event) -> list[Reply]`, channel-agnostic.
Pipeline for one incoming event (SPEC §3, guardrails #1, #2, #3):
text event
1. scrub() raw text → ScrubbedText. Raw text is never stored, logged or sent anywhere.
2. consent gate no valid consent → no LLM, nothing stored; only the local crisis lexicon runs
(protecting life does not need consent) and the core flow shows consent/refusal.
3. safety.check() lexicon FIRST. Lexicon ACUTE → the LLM is not called at all. Otherwise the LLM risk
classifier is an auxiliary signal combined as max(lexicon, llm): it can only raise risk.
ACUTE → fixed crisis script verbatim (once per open crisis), state `crisis`, structured
`safety_events` fact for lane B, MonitoringService.after_data_change() (lane B alerts).
POSSIBLE → C-SSRS screen (scale session) and back to the previous state (or crisis if high).
4. FSM the current state's handler (`app/dialog/flows/*`, `app/dialog/scale_session.py`).
button / system / start events skip 1–3 (no free text) and go through the same FSM.
After the FSM step: if clinical data were written → commit → `after_data_change()` → the returned *dialog*
actions are executed (`app/dialog/actions.py`). The engine never creates alerts (ADR-007).
Then a queued scale starts if the FSM rests (idle; only the C-SSRS while in crisis).
Persist: inbound message (scrubbed) + outbound replies + `dialog_state` — only with active consent.
Logs carry only the token, states and event codes (guardrail #8).
"""
import asyncio
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from weakref import WeakValueDictionary
from sqlalchemy.exc import InterfaceError, OperationalError
from sqlalchemy.ext.asyncio import AsyncSession
from app.core import clock, safety
from app.core.intake_runtime import intake_enabled
from app.core.privacy import scrub
from app.core.safety import SafetyResult, crisis_reply
from app.core.types import RiskLevel, ScrubbedText
from app.db import repo
from app.db.session import get_clinical_session
from app.dialog import scale_session
from app.dialog.actions import execute
from app.dialog.contracts import Event, Reply, register_engine
from app.dialog.crisis import crisis_open, no_consent_crisis_text, open_crisis
from app.dialog.flows import load_flows
from app.dialog.flows.core import close_crisis, revoke
from app.dialog.fsm import NO_CONSENT_STATES, RESTING_STATES, Ctx, Step, apply_data, enter_hook_for, handler_for
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)
@dataclass
class _Run:
replies: list[Reply] = field(default_factory=list)
notify: bool = False
def _consented(ctx: Ctx) -> bool:
return ctx.data.get("consent") is True and ctx.state not in NO_CONSENT_STATES
class Engine:
"""Real `DialogEngine`. `gateway` / `monitoring` are injectable for tests; by default resolved per call so
a monitoring implementation registered later (lane B, #38) is picked up."""
def __init__(self, *, gateway: LLMGateway | None = None, monitoring: MonitoringService | None = None) -> None:
self._gateway = gateway
self._monitoring = monitoring
async def handle(self, token: str, event: Event) -> list[Reply]:
texts = load_dialog_texts()
try:
lock = _patient_locks.setdefault(token, asyncio.Lock())
async with lock:
async with get_clinical_session() as session:
await intake_service.lock_patient(session, token)
replies = await self._handle(token, event, session, texts)
await intake_service.dispatch_notices(token)
return replies
except _DB_DOWN as exc:
log.error("dialog db unavailable token=%s event=%s error=%s", token, event.type, type(exc).__name__)
return _degraded(event, texts)
# ------------------------------------------------------------------------------------------ pipeline
async def _handle(self, token: str, event: Event, session: AsyncSession, texts: DialogTexts) -> list[Reply]:
row = await repo.get_dialog_state(session, token)
ctx = Ctx(
token=token,
state=row.state if row is not None else "new",
data=dict(row.data or {}) if row is not None else {},
now=clock.now(),
session=session,
gateway=self._gateway or get_gateway(),
monitoring=self._monitoring or get_monitoring(),
texts=texts,
)
state_before = ctx.state
run = _Run()
await self._apply(ctx, await self._route(ctx, event), run)
if run.notify:
# Monitoring (lane B) reads the DB in its own session: commit our writes first.
await self._save_state(ctx)
await session.commit()
actions = await ctx.monitoring.after_data_change(token)
await self._apply(ctx, await execute(ctx, actions), run)
await self._apply(ctx, await self._start_pending(ctx), run)
if _consented(ctx):
for reply in run.replies:
# Outgoing texts may quote the safety plan (names of relatives) — scrub before storing too.
await repo.add_message(
session, token=token, direction="out", text=scrub(reply.text), ts=ctx.now, fsm_state=ctx.state
)
await self._save_state(ctx)
task_id = event.params.get("questionnaire_task")
if event.type == "system" and task_id and run.replies:
from app.db.intake import QuestionnaireTask
task = await session.get(QuestionnaireTask, task_id)
if task and task.token == token:
task.delivery = [r.model_dump() for r in run.replies]
log.info(
"dialog token=%s event=%s code=%s state=%s->%s replies=%d",
token,
event.type,
_event_code(event),
state_before,
ctx.state,
len(run.replies),
)
return run.replies
async def _route(self, ctx: Ctx, event: Event) -> Step:
consented = _consented(ctx)
if event.type == "text":
# (1) scrub before anything else: from here on only ScrubbedText exists for this message.
ctx.text = scrub(event.text or "")
if consented and _is_revoke_command(event.text or "", ctx.texts):
return await revoke(ctx)
if not consented:
return await self._no_consent_text(ctx, event)
await self._store_in(ctx, ctx.text)
return await self._safety(ctx, event)
if event.type == "button":
if consented and event.button == "consent:revoke":
return await revoke(ctx)
if consented and event.button == "intake:help":
from app.db.intake import QuestionnaireRecord
from app.dialog.flows.intake import urgent
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
return await urgent(ctx, row)
if consented:
await self._store_in(ctx, scrub(f"[button] {event.button}")) # button data comes from the client
return await self._dispatch(ctx, event)
if event.type == "system":
if event.system == "doctor_ack":
return close_crisis(ctx)
if not consented:
return Step() # nothing is pushed to people without consent
if event.system == "scale_due" and event.params.get("scale"):
if intake_enabled() and ctx.data.get("adult") is True:
scale = str(event.params["scale"])
kind = "cssrs_since_contact" if scale in ("cssrs_screen", "cssrs") else scale
if kind in ("phq9", "pcl5", "rus_audit_s", "cssrs_since_contact"):
await intake_service.queue_task(
ctx.session, ctx.token, kind, f"external:{kind}:{ctx.now.date()}", ctx.now
)
return Step()
scheduled = bool(event.params.get("scheduled", True))
pending = scale_session.add_pending(ctx.data, str(event.params["scale"]), scheduled)
return Step(data={"pending_scales": pending}) # starts now if resting, else after the current step
return await self._dispatch(ctx, event)
return await self._dispatch(ctx, event) # start
async def _no_consent_text(self, ctx: Ctx, event: Event) -> Step:
assert ctx.text is not None
# Lexicon only: without consent the text must not reach the LLM (third-party processing).
result = safety.check(ctx.text)
if result.level == RiskLevel.ACUTE:
log.warning("safety acute without consent token=%s rules=%s", ctx.token, result.matched_rules)
return Step(replies=[Reply(text=no_consent_crisis_text(crisis_reply(result.categories)), kind="crisis")])
return await self._dispatch(ctx, event)
async def _safety(self, ctx: Ctx, event: Event) -> Step:
"""Guardrail #2: the safety decision is made before any LLM sees the message for anything else."""
assert ctx.text is not None
lexicon = safety.check(ctx.text)
if lexicon.level == RiskLevel.ACUTE:
result = lexicon # an acute message never reaches the LLM — not even the risk classifier
else:
result = safety.check(ctx.text, llm_signal=await ctx.gateway.risk_classify(ctx.text))
if result.level == RiskLevel.ACUTE:
handled = "crisis_repeat" if crisis_open(ctx.data) else "crisis_script"
log.warning("safety acute token=%s rules=%s handled=%s", ctx.token, result.matched_rules, handled)
if ctx.data.get("questionnaire_record") or (intake_enabled() and ctx.data.get("adult") is True):
from app.db.intake import QuestionnaireRecord
from app.dialog.flows.intake import urgent
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
step = await urgent(ctx, row, code="safety_acute", categories=result.categories)
else:
step = open_crisis(ctx, result.categories, source=f"safety:{result.source}")
step.data["safety_events"] = self._safety_events(ctx, result, handled)
step.notify_monitoring = True # lane B escalates to the doctor on call
return step
if result.level == RiskLevel.POSSIBLE and (
ctx.data.get("questionnaire_record") or (intake_enabled() and ctx.data.get("adult") is True)
):
from app.db.intake import QuestionnaireRecord
from app.dialog.flows.intake import manual_review
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
if row is None:
row = await intake_service.create_record(ctx, "cssrs_since_contact")
return await manual_review(ctx, row, "cssrs_since_contact_required")
if result.level == RiskLevel.POSSIBLE:
screen = ctx.texts.settings.risk_screen_scale
if self._can_screen(ctx, screen):
step = await scale_session.start_scale(
ctx, screen, return_state=ctx.state, scheduled=False, intro=ctx.texts.scale.risk_screen_intro
)
step.data["risk_screen_at"] = ctx.now.isoformat()
handled = "risk_screen"
else:
step = await self._dispatch(ctx, event)
handled = "dispatched"
log.info("safety possible token=%s rules=%s handled=%s", ctx.token, result.matched_rules, handled)
step.data["safety_events"] = self._safety_events(ctx, result, handled)
step.notify_monitoring = True
return step
return await self._dispatch(ctx, event)
def _can_screen(self, ctx: Ctx, screen: str) -> bool:
if crisis_open(ctx.data) or scale_session.active_scale(ctx.state) == screen:
return False
last = ctx.data.get("risk_screen_at")
if last is None:
return True
hours = (ctx.now - datetime.fromisoformat(last)).total_seconds() / 3600
return hours >= ctx.texts.settings.cssrs_cooldown_hours
def _safety_events(self, ctx: Ctx, result: SafetyResult, handled: str) -> list[dict[str, Any]]:
"""Structured safety fact for lane B (#38): no text, only codes. Newest last, capped."""
event = {
"ts": ctx.now.isoformat(),
"level": result.level.name.lower(),
"categories": result.categories,
"rules": result.matched_rules,
"source": result.source,
"handled": handled,
"state": ctx.state,
}
events = [*(ctx.data.get("safety_events") or []), event]
return events[-ctx.texts.settings.max_safety_events :]
async def _dispatch(self, ctx: Ctx, event: Event) -> Step:
handler = handler_for(ctx.state)
if handler is None:
# A state from a removed/renamed flow: recover instead of getting stuck.
log.error("dialog unknown state token=%s state=%s", ctx.token, ctx.state)
return Step(next_state="idle" if _consented(ctx) else "new")
return await handler(ctx, event)
async def _apply(self, ctx: Ctx, step: Step, run: _Run, depth: int = 0) -> None:
run.replies.extend(step.replies)
apply_data(ctx.data, step.data)
run.notify = run.notify or step.notify_monitoring
if not step.next_state or step.next_state == ctx.state:
return
ctx.state = step.next_state
hook = enter_hook_for(ctx.state)
if hook is None:
return
if depth >= MAX_ENTER_CHAIN:
log.error("dialog enter chain too long token=%s state=%s", ctx.token, ctx.state)
return
await self._apply(ctx, await hook(ctx), run, depth + 1)
async def _start_pending(self, ctx: Ctx) -> Step:
"""Start a queued scale when nothing else is being asked. In crisis only the risk screen may run."""
if intake_enabled():
return Step()
pending = list(ctx.data.get("pending_scales") or [])
if not pending or ctx.state not in RESTING_STATES:
return Step()
screen = ctx.texts.settings.risk_screen_scale
if ctx.state == "crisis":
pending_screen = [p for p in pending if p["scale"] == screen]
if not pending_screen:
return Step()
item = pending_screen[0]
else:
item = pending[0]
rest = [p for p in pending if p is not item]
step = await scale_session.start_scale(
ctx,
item["scale"],
return_state=ctx.state,
scheduled=bool(item.get("scheduled", True)),
intro=ctx.texts.scale.risk_screen_intro if item["scale"] == screen else None,
)
step.data["pending_scales"] = rest or None
return step
async def _store_in(self, ctx: Ctx, text: ScrubbedText) -> None:
await repo.add_message(ctx.session, token=ctx.token, direction="in", text=text, ts=ctx.now, fsm_state=ctx.state)
async def _save_state(self, ctx: Ctx) -> None:
await repo.set_dialog_state(ctx.session, token=ctx.token, state=ctx.state, data=ctx.data, now=ctx.now)
def _is_revoke_command(raw: str, texts: DialogTexts) -> bool:
return raw.strip().lower().rstrip(".!") in texts.commands.revoke
def _event_code(event: Event) -> str:
if event.type == "button":
return event.button or ""
if event.type == "system":
return event.system or ""
return "-"
def _degraded(event: Event, texts: DialogTexts) -> list[Reply]:
"""DB down: still answer a crisis message (lexicon only, no "doctor notified" — nobody could be notified)."""
if event.type == "text" and event.text:
result = safety.check(scrub(event.text))
if result.level == RiskLevel.ACUTE:
return [Reply(text=no_consent_crisis_text(crisis_reply(result.categories)), kind="crisis")]
if event.type == "system":
return []
return [Reply(text=texts.unavailable, kind="info")]
load_flows()
register_engine(Engine)
+16
View File
@@ -0,0 +1,16 @@
"""Dialog flows. Each module here registers its FSM states at import time (`@state`, `@on_enter`, `@trigger`).
A new direction (veteran #36/#37, family, depression) is a new file `app/dialog/flows/<module>.py`; it is picked
up automatically by `load_flows()` — the engine is not edited.
"""
import pkgutil
from importlib import import_module
def load_flows() -> None:
"""Import every flow module of this package (core first, so flows may override core triggers)."""
import_module(f"{__name__}.core")
for info in sorted(pkgutil.iter_modules(__path__), key=lambda m: m.name):
if info.name != "core" and not info.name.startswith("_"):
import_module(f"{__name__}.{info.name}")
+352
View File
@@ -0,0 +1,352 @@
"""Core flow shared by all modules: connect → consent → age gate → module onboarding → idle; crisis; revocation.
new ──start(code)──▶ consent ──«Согласен»──▶ age_gate ──adult──▶ <module>.onboarding.start … ──▶ idle
│ └──< 18──▶ minor (stop, child helpline)
└──«Не согласен»──▶ refused (stop, /start to reconsider)
any consented state ──«Отозвать согласие»──▶ consent_revoked (silent; /start → consent again)
any ──ACUTE / item 9 / C-SSRS high──▶ crisis ──doctor_ack──▶ idle
Consent and age are the registration step, so this module (and only it) touches the identity DB, through
`app.db.identity_repo`, and only for consent / revocation / birth year (guardrail #5).
"""
import re
from app.db import identity_repo, repo
from app.db.session import get_identity_session
from app.dialog.actions import safety_plan_reply
from app.dialog.contracts import Button, Event, Reply
from app.dialog.fsm import Ctx, Step, get_trigger, is_registered, on_enter, state, trigger
from app.integrations.base import integrations
from app.services.contracts import NoopMonitoring
ADULT_AGE = 18
_YEAR_RE = re.compile(r"\b(19\d{2}|20\d{2})\b")
_AGE_RE = re.compile(r"^\s*(\d{1,3})\s*(лет|год|года)?\s*$")
# ------------------------------------------------------------------------------------------- replies
def consent_reply(ctx: Ctx) -> Reply:
t = ctx.texts.consent
buttons = [[Button(label=t.accept, data="consent:yes")], [Button(label=t.decline, data="consent:no")]]
return Reply(text=t.text, buttons=buttons, kind="question")
def menu_reply(ctx: Ctx, text: str | None = None) -> Reply:
b = ctx.texts.idle.buttons
buttons = [
[Button(label=b.checkin, data="menu:checkin")],
[Button(label=b.med_question, data="menu:med_question")],
[Button(label=b.contact_doctor, data="menu:contact_doctor")],
[Button(label=b.revoke, data="consent:revoke")],
]
from app.core.intake_questions import texts
from app.core.intake_runtime import intake_enabled
if intake_enabled():
buttons.insert(0, [Button(label=texts()["help"], data="intake:help")])
buttons.insert(
0,
[
Button(
label=texts()["resume"] if ctx.data.get("questionnaire_record") else texts()["start"],
data="intake:resume" if ctx.data.get("questionnaire_record") else "intake:start",
)
],
)
return Reply(text=text or ctx.texts.idle.menu_prompt, buttons=buttons)
# ------------------------------------------------------------------------------------------- onboarding API
async def onboarding_entry(ctx: Ctx, after: str | None = None) -> str:
"""Entry state of the next module onboarding: `"{module}.onboarding.start"` for the first module of the
profile (after `after`, if given) that has such a state registered; `"idle"` when none is left."""
profile = await ctx.profile()
modules = list(profile.modules) if profile is not None and profile.modules else []
from app.core.intake_runtime import intake_enabled
if after is None and intake_enabled() and any(m in modules for m in ("veteran", "depression")):
return "intake.start"
if after in modules:
modules = modules[modules.index(after) + 1 :]
for module in modules:
if is_registered(f"{module}.onboarding.start"):
return f"{module}.onboarding.start"
return "idle"
async def finish_onboarding(ctx: Ctx, module: str | None = None) -> Step:
"""A module flow calls this when its onboarding is done: next module's onboarding, or idle + welcome."""
nxt = await onboarding_entry(ctx, after=module)
if nxt == "idle":
return Step(replies=[menu_reply(ctx, ctx.texts.idle.welcome)], next_state="idle")
return Step(next_state=nxt)
# ------------------------------------------------------------------------------------------- new / consent
@state("new")
async def _new(ctx: Ctx, event: Event) -> Step:
code = event.params.get("code") if event.type == "start" else None
if code:
# Telegram deep link carries the doctor's one-time connect code; the web console passes the token
# directly (no code) — then the token is already known to the system.
person = await integrations.identity.verify(str(code))
if person is None or person.token != ctx.token:
return Step(replies=[Reply(text=ctx.texts.consent.code_invalid)])
if await ctx.profile() is None:
await repo.upsert_profile(ctx.session, token=ctx.token, doctor_id=person.doctor_id)
return Step(replies=[consent_reply(ctx)], next_state="consent")
@state("consent")
async def _consent(ctx: Ctx, event: Event) -> Step:
if event.type == "button" and event.button == "consent:yes":
version = ctx.texts.consent.version
async with get_identity_session() as s:
# None when the token has no identity record (web demo console) — the dialog state keeps the fact.
await identity_repo.record_consent(s, token=ctx.token, version=version, now=ctx.now)
data = {"consent": True, "consent_ver": version, "consent_at": ctx.now.isoformat(), "revoked_at": None}
return Step(next_state="age_gate", data=data)
if event.type == "button" and event.button == "consent:no":
return _refused_step(ctx)
# Anything else (incl. free text) is not processed before consent: show the consent text again.
return Step(replies=[consent_reply(ctx)])
def _refused_step(ctx: Ctx) -> Step:
button = Button(label=ctx.texts.consent.reconsider, data="consent:restart")
return Step(replies=[Reply(text=ctx.texts.consent.refused, buttons=[[button]])], next_state="refused")
@state("refused")
async def _refused(ctx: Ctx, event: Event) -> Step:
if event.type == "start" or (event.type == "button" and event.button == "consent:restart"):
return Step(replies=[consent_reply(ctx)], next_state="consent")
if event.type == "system":
return Step()
return _refused_step(ctx)
# ------------------------------------------------------------------------------------------- age gate
def _age_verdict(birth_year: int, current_year: int) -> str:
"""Without the birthday the age is `current - birth_year` or one less; ask when the two disagree on 18."""
oldest, youngest = current_year - birth_year, current_year - birth_year - 1
if youngest >= ADULT_AGE:
return "adult"
if oldest < ADULT_AGE:
return "minor"
return "unsure"
def _age_question(ctx: Ctx, note: str = "") -> Reply:
t = ctx.texts.age_gate
buttons = [[Button(label=t.adult, data="age:adult")], [Button(label=t.minor, data="age:minor")]]
return Reply(text="\n\n".join(p for p in (note, t.question) if p), buttons=buttons, kind="question")
@on_enter("age_gate")
async def _age_enter(ctx: Ctx) -> Step:
profile = await ctx.profile()
birth_year = profile.birth_year if profile is not None else None
if birth_year is None:
async with get_identity_session() as s:
patient = await identity_repo.get_patient(s, ctx.token)
birth_year = patient.birth_year if patient is not None else None
if birth_year is not None:
verdict = _age_verdict(birth_year, ctx.now.year)
if verdict == "adult":
return await _adult(ctx)
if verdict == "minor":
return await _minor(ctx)
return Step(replies=[_age_question(ctx)])
@state("age_gate")
async def _age_gate(ctx: Ctx, event: Event) -> Step:
if event.type == "button" and event.button == "age:adult":
return await _adult(ctx)
if event.type == "button" and event.button == "age:minor":
return await _minor(ctx)
if event.type == "text" and ctx.text:
year = _YEAR_RE.search(ctx.text)
age_match = _AGE_RE.match(ctx.text)
if year:
birth_year = int(year.group(1))
verdict = _age_verdict(birth_year, ctx.now.year)
if verdict == "adult":
return await _adult(ctx, birth_year)
if verdict == "minor":
return await _minor(ctx)
elif age_match:
age = int(age_match.group(1))
return await (_adult(ctx) if age >= ADULT_AGE else _minor(ctx))
return Step(replies=[_age_question(ctx, ctx.texts.age_gate.unclear)])
return Step(replies=[_age_question(ctx)])
async def _adult(ctx: Ctx, birth_year: int | None = None) -> Step:
if birth_year is not None and await ctx.profile() is not None:
await repo.upsert_profile(ctx.session, token=ctx.token, birth_year=birth_year)
return Step(data={"adult": True}).then(await finish_onboarding(ctx))
async def _minor(ctx: Ctx) -> Step:
# A minor cannot give this consent on their own: withdraw it and stop processing.
async with get_identity_session() as s:
await identity_repo.revoke_consent(s, token=ctx.token, now=ctx.now)
return Step(
replies=[Reply(text=ctx.texts.age_gate.minor_stop, kind="info")],
next_state="minor",
data={"consent": False, "minor": True},
)
@state("minor")
async def _minor_state(ctx: Ctx, event: Event) -> Step:
if event.type == "system":
return Step()
return Step(replies=[Reply(text=ctx.texts.age_gate.minor_stop, kind="info")])
# ------------------------------------------------------------------------------------------- idle
@state("idle")
async def _idle(ctx: Ctx, event: Event) -> Step:
if event.type == "start":
return Step(replies=[menu_reply(ctx)])
if event.type == "button" and event.button:
handler = get_trigger(f"button:{event.button}")
return await handler(ctx, event) if handler else Step(replies=[menu_reply(ctx)])
if event.type == "system" and event.system:
handler = get_trigger(f"system:{event.system}")
return await handler(ctx, event) if handler else Step()
if event.type == "text":
handler = get_trigger("text:idle")
if handler:
return await handler(ctx, event)
# Neutral empathic reply; the LLM only rephrases the approved template (and is filtered).
return Step(replies=[menu_reply(ctx, await ctx.gateway.phrase("support"))])
return Step()
# Default idle triggers — flows (#15, #36, #37) override them with `@trigger(..., replace=True)`.
@trigger("button:menu:checkin")
async def _menu_checkin(ctx: Ctx, event: Event) -> Step:
return Step(replies=[Reply(text=ctx.texts.idle.stub_checkin)])
@trigger("button:menu:med_question")
async def _menu_med_question(ctx: Ctx, event: Event) -> Step:
return Step(replies=[Reply(text=ctx.texts.idle.stub_med_question)])
@trigger("button:menu:contact_doctor")
async def _menu_contact_doctor(ctx: Ctx, event: Event) -> Step:
# A fact for lane B (#38): the patient asked the doctor to get in touch. No alert is created here (ADR-007).
requests = [*(ctx.data.get("contact_requests") or []), {"ts": ctx.now.isoformat()}][-20:]
return Step(
replies=[Reply(text=ctx.texts.idle.contact_doctor_ack)],
data={"contact_requests": requests},
notify_monitoring=True,
)
@trigger("button:menu:visit")
async def _menu_visit(ctx: Ctx, event: Event) -> Step:
return Step(replies=[Reply(text=ctx.texts.idle.visit_stub)])
@trigger("button:safety_plan")
async def _show_plan(ctx: Ctx, event: Event) -> Step:
return Step(replies=[await safety_plan_reply(ctx)])
@trigger("system:med_reminder")
async def _med_reminder(ctx: Ctx, event: Event) -> Step:
drug = str(event.params.get("drug", ""))
return Step(replies=[Reply(text=await ctx.gateway.phrase("med_reminder", {"drug": drug}))])
@trigger("system:daily_checkin")
async def _daily_checkin(ctx: Ctx, event: Event) -> Step:
return Step(replies=[menu_reply(ctx, ctx.texts.idle.stub_checkin)])
@trigger("system:reach_out")
async def _reach_out(ctx: Ctx, event: Event) -> Step:
return Step(replies=[Reply(text=ctx.texts.reach_out.default)])
# ------------------------------------------------------------------------------------------- crisis
def close_crisis(ctx: Ctx) -> Step:
"""Doctor acknowledged the crisis: clear the flag; a C-SSRS still running returns to idle afterwards."""
if not (ctx.data.get("crisis") or {}).get("open") and ctx.state != "crisis":
return Step()
crisis = {**(ctx.data.get("crisis") or {}), "open": False, "closed_at": ctx.now.isoformat()}
scales = {
sid: {**s, "return_state": "idle"} if s.get("return_state") == "crisis" else s
for sid, s in (ctx.data.get("scales") or {}).items()
}
step = Step(data={"crisis": crisis, "scales": scales})
if ctx.state == "crisis":
step.next_state = "idle"
step.replies.append(Reply(text=ctx.texts.crisis.closed))
return step
@state("crisis")
async def _crisis(ctx: Ctx, event: Event) -> Step:
# The NoopMonitoring stub knows nothing about crises and always says "not in crisis"; trusting it would
# end every crisis on the next message. Until lane B (#38) lands only `doctor_ack` ends a crisis.
if not isinstance(ctx.monitoring, NoopMonitoring) and not await ctx.monitoring.is_in_crisis(ctx.token):
return close_crisis(ctx)
if event.type == "system":
return Step() # reminders and check-ins wait until the crisis is over
if event.type == "button" and event.button == "safety_plan":
return Step(replies=[await safety_plan_reply(ctx)])
plan = Button(label=ctx.texts.safety_plan.title.rstrip(":"), data="safety_plan")
return Step(replies=[Reply(text=ctx.texts.crisis.hold, buttons=[[plan]], kind="crisis")])
# ------------------------------------------------------------------------------------------- revocation
async def revoke(ctx: Ctx) -> Step:
"""Withdraw consent in one step (152-FZ art. 9 p. 2): identity DB + dialog state; the bot goes silent.
Clinical data are marked for deletion (`data["delete_requested"]`) — erasure itself is lane B's job.
"""
async with get_identity_session() as s:
await identity_repo.revoke_consent(s, token=ctx.token, now=ctx.now)
from app.services.questionnaire import cancel_pending
await cancel_pending(ctx.session, ctx.token)
data = {
"questionnaire_record": None,
"consent": False,
"revoked_at": ctx.now.isoformat(),
"delete_requested": True,
"scales": None,
"pending_scales": None,
}
return Step(replies=[Reply(text=ctx.texts.revoked.confirm)], next_state="consent_revoked", data=data)
@state("consent_revoked")
async def _revoked(ctx: Ctx, event: Event) -> Step:
if event.type == "start":
return Step(replies=[consent_reply(ctx)], next_state="consent")
return Step() # silent: nothing is processed after revocation
+376
View File
@@ -0,0 +1,376 @@
"""Versioned, resumable questionnaires. Disabled until the clinical release gate opens."""
import math
from dataclasses import asdict, replace
from zoneinfo import ZoneInfo
from app.core.intake_questions import (
content,
cssrs_answers,
diary_questions,
group_answers,
next_intake,
scale_question,
texts,
)
from app.core.intake_runtime import diary_flags, intake_enabled, load_runtime
from app.core.questionnaire import alcohol_expansion, load_policy, score_alcohol, score_cssrs_recent
from app.core.scales import load_scale
from app.core.types import AlertLevel
from app.db.intake import QuestionnaireRecord, QuestionnaireTask
from app.dialog.contracts import Button, Event, Reply
from app.dialog.crisis import crisis_open, open_crisis
from app.dialog.fsm import Step, on_enter, state, trigger
from app.services import questionnaire as service
def allowed(ctx):
return ctx.data.get("consent") is True and ctx.data.get("adult") is True and intake_enabled()
def next_question(row):
a = row.answers
if row.kind == "intake":
return next_intake(a, load_policy(), row.details.get("sex"))
if row.kind == "diary":
for q in diary_questions(
row.details["modules"], suicide_monitoring=row.details["suicide_monitoring"], weekday=row.day.weekday()
):
if q.id in a:
continue
if q.id == "medication_reason" and a.get("medication") not in ("partial", "none"):
continue
if q.id == "side_effects" and a.get("medication") == "not_prescribed":
continue
if q.id == "thought_intensity" and a.get("death_thoughts") != 1:
continue
return q
return None
if row.kind in ("phq9", "pcl5", "rus_audit_s"):
sid = row.kind
if sid == "rus_audit_s":
short = score_alcohol(
{k: a[k] for k in load_scale(sid).item_ids if k in a}, short=True, sex=row.details.get("sex")
)
if alcohol_expansion(short, load_policy()):
sid = "rus_audit"
for item in load_scale(sid).item_ids:
if item == "rus_audit_2" and a.get("rus_audit_1") == 0:
continue
if item not in a:
q = scale_question(sid, item)
if sid == "pcl5" and row.details["weekly_pcl"]:
q = replace(q, text=q.text.replace("последний месяц", "последнюю неделю"), period="past_week")
return q
if row.details.get("medication_active") and "side_effects" not in a:
return next(
q
for q in diary_questions([], suicide_monitoring=False, weekday=row.day.weekday())
if q.id == "side_effects"
)
return None
def question_reply(row, q):
t = texts()
# Nonce + item + option index rejects old/replayed callbacks without exposing raw answers.
prefix = f"iq:{row.id[:12]}:{q.id}:"
buttons = [[Button(label=label, data=prefix + str(i))] for i, (_, label) in enumerate(q.options)]
if q.optional:
buttons.append([Button(label=t["comment_skip"], data=prefix + "skip")])
buttons.extend([[Button(label=t["help"], data="intake:help")], [Button(label=t["pause"], data="intake:pause")]])
return Reply(text=q.text + "\n\n" + t["as_of"].format(day=row.day.isoformat()), buttons=buttons, kind="question")
async def urgent(ctx, row=None, *, code="help_now", categories=None):
if crisis_open(ctx.data):
return open_crisis(ctx, categories or [], source="questionnaire:" + code)
profile = await ctx.profile()
if profile:
profile.enhanced_monitoring = True
flags = list(service.decision(row).flags) if row else []
if row and (row.answers.get("death_thoughts") == 1 or row.answers.get("phq9_9", 0) > 0):
flags.append("cssrs_since_contact_required")
if row:
await service.finish(ctx, row, "interrupted", extra={"stop_reason": code, "flags": flags})
await service.alert(
ctx.session,
ctx.token,
f"{row.id if row else ctx.now.isoformat()}:{code}",
code,
ctx.now,
level=AlertLevel.ACUTE,
params={"record_id": row.id if row else None, "flags": flags},
)
step = open_crisis(ctx, categories or [], source="questionnaire:" + code)
step.data["questionnaire_record"] = None
step.data["intake_review_required"] = True
for reply in step.replies:
reply.buttons.append([Button(label=ctx.texts.safety_plan.title.rstrip(":"), data="safety_plan")])
return step
async def manual_review(ctx, row, code):
await service.finish(ctx, row, "review_required", extra={"stop_reason": code})
await service.alert(
ctx.session, ctx.token, f"{row.id}:{code}", code, ctx.now, level=AlertLevel.HIGH, params={"record_id": row.id}
)
return Step(
replies=[
Reply(
text=texts()["missing_followup"] if code == "cssrs_since_contact_required" else texts()["review"],
buttons=[[Button(label=texts()["help"], data="intake:help")]],
kind="info",
)
],
next_state="idle",
data={"questionnaire_record": None, "intake_review_required": True},
)
async def advance(ctx, row):
a = row.answers
c = score_cssrs_recent(cssrs_answers(a))
policy = load_policy()
if a.get("phq9_9", 0) > 0 or a.get("death_thoughts") == 1 or c.risk_lower_bound == "high":
return await urgent(ctx, row, code="suicide_signal")
if c.positive and policy.cssrs_positive_policy == "stop_any":
return await urgent(ctx, row, code="cssrs_positive")
if c.conflicts:
return await manual_review(ctx, row, "cssrs_conflict")
# Finish the risk questions before any routine question; a moderate result stops here.
if c.complete and c.risk == "moderate":
return await manual_review(ctx, row, "urgent_psychiatrist")
if c.complete and c.risk == "low":
profile = await ctx.profile()
if profile:
profile.enhanced_monitoring = True
await service.alert(
ctx.session,
ctx.token,
row.id + ":cssrs_low",
"cssrs_low_safety_plan",
ctx.now,
params={"record_id": row.id},
)
if row.kind == "cssrs_since_contact":
# Recent and Since Last Contact are different instruments. No silent substitution.
return await manual_review(ctx, row, "cssrs_since_contact_required")
try:
q = next_question(row)
# Validate preserved short answers against a newly answered full-form item 1.
score_alcohol(group_answers(a, "rus_audit"), short=False, sex=row.details.get("sex"))
except ValueError:
return await manual_review(ctx, row, "answer_conflict")
if q:
replies = [question_reply(row, q)]
if c.complete and c.risk == "low" and not row.details.get("plan_shown"):
from app.dialog.actions import safety_plan_reply
replies.insert(0, await safety_plan_reply(ctx))
row.details = {**row.details, "plan_shown": True}
return Step(replies=replies, next_state="intake.collect", data={"questionnaire_record": row.id})
extra = {}
if a.get("side_effects") == 1:
await service.alert(
ctx.session,
ctx.token,
row.id + ":side_effects_review",
"side_effects_review",
ctx.now,
params={"record_id": row.id},
)
if row.kind == "intake":
result = service.decision(row)
if result.status != "ready":
return await manual_review(ctx, row, "intake_incomplete_review")
extra = {"decision": asdict(result), "totals": service.summary(row)}
if a.get("recent_trauma") == 1:
extra["recent_trauma"] = True
if result.primary_route != "self_help" or result.flags or result.parallel_routes or a.get("recent_trauma"):
await service.alert(
ctx.session,
ctx.token,
row.id + ":route",
"intake_route_review",
ctx.now,
params={"record_id": row.id, **extra},
)
elif row.kind == "diary":
history = {
r.day: r.answers
for r in await service.records(ctx.session, ctx.token)
if r.kind == "diary" and r.status == "complete"
}
history[row.day] = a
flags = diary_flags(history, row.day)
extra["flags"] = flags
for flag in flags:
await service.alert(
ctx.session, ctx.token, row.id + ":" + flag, flag, ctx.now, params={"record_id": row.id}
)
if flag == "phq9_due_mood":
await service.queue_task(ctx.session, ctx.token, "phq9", row.id + ":phq9", ctx.now)
else:
extra["totals"] = service.summary(row)
if row.kind in ("phq9", "pcl5"):
sid = row.kind
total = extra["totals"][sid]["total"]
priority = getattr(policy, sid + "_priority_at")
specialist = getattr(policy, sid + "_specialist_at")
extra["route"] = (
"priority_psychiatrist"
if total >= priority
else ("psychiatrist_or_psychotherapist" if total >= specialist else "review_with_treating_clinician")
)
prior = next(
(
r
for r in reversed(await service.records(ctx.session, ctx.token))
if r.id != row.id
and r.status == "complete"
and r.version == row.version
and r.details.get("totals", {}).get(sid, {}).get("complete")
and (sid != "pcl5" or r.details.get("weekly_pcl", False) == row.details.get("weekly_pcl", False))
),
None,
)
if prior:
change = total - prior.details["totals"][sid]["total"]
extra["delta"] = {
"baseline_record": prior.id,
"value": change,
"retriage": change >= getattr(load_runtime(), sid + "_retriage_delta"),
}
if row.kind == "rus_audit_s" and not row.details.get("sex"):
return await manual_review(ctx, row, "alcohol_sex_unknown")
if extra["totals"]["rus_audit"].get("zone") == 4:
extra["parallel_routes"] = ["narcologist"]
retriage = extra.get("delta", {}).get("retriage", False)
await service.alert(
ctx.session,
ctx.token,
row.id + ":followup",
"followup_retriage_required" if retriage else "followup_review",
ctx.now,
level=AlertLevel.HIGH if retriage or extra.get("route") == "priority_psychiatrist" else AlertLevel.MODERATE,
params={"record_id": row.id, **extra},
)
await service.finish(ctx, row, "complete", extra=extra)
from app.dialog.flows.core import menu_reply
replies = [menu_reply(ctx, texts()["done"])]
alcohol_total = extra.get("totals", {}).get("rus_audit", {})
if alcohol_total.get("zone") in (2, 3):
replies.insert(0, Reply(text=texts()["alcohol_advice"], kind="info"))
return Step(replies=replies, next_state="idle", data={"questionnaire_record": None})
async def start(ctx, kind="intake", task=None):
if not allowed(ctx) or crisis_open(ctx.data):
if task:
return Step()
return Step(replies=[Reply(text=texts()["unavailable"], kind="info")])
existing = await service.records(ctx.session, ctx.token)
active = next((r for r in reversed(existing) if r.status in ("active", "paused")), None)
if active:
if task and task.record_id != active.id:
return Step()
if (
active.policy_version != load_policy().version
or active.version != content()["version"]
or active.details.get("runtime_version") != load_runtime().version
):
return await manual_review(ctx, active, "questionnaire_version_changed")
active.status = "active"
return await advance(ctx, active)
if ctx.data.get("intake_review_required") and kind != "cssrs_since_contact":
if task:
return Step()
return Step(replies=[Reply(text=texts()["review"], kind="info")])
row = await service.create_record(ctx, kind, task=task)
return await advance(ctx, row)
@on_enter("intake.start")
async def enter(ctx):
return await start(ctx)
@trigger("button:intake:start")
@trigger("button:intake:resume")
async def start_button(ctx, event):
return await start(ctx)
@trigger("button:menu:checkin", replace=True)
@trigger("system:daily_checkin", replace=True)
async def checkin(ctx, event):
if not intake_enabled():
return Step(replies=[Reply(text=ctx.texts.idle.stub_checkin)])
if event.params.get("questionnaire_task"):
task = await ctx.session.get(QuestionnaireTask, event.params["questionnaire_task"])
if task is None or task.token != ctx.token or task.status not in ("pending", "active"):
return Step()
return await start(ctx, task.kind, task)
existing = await service.records(ctx.session, ctx.token)
if not any(r.kind == "intake" and r.status == "complete" for r in existing):
return await start(ctx)
day = ctx.now.astimezone(ZoneInfo(load_runtime().timezone)).date()
if any(r.kind == "diary" and r.day == day and r.status == "complete" for r in existing):
return Step(replies=[Reply(text=texts()["done"], kind="info")])
risk_task = await service.queue_task(ctx.session, ctx.token, "cssrs_since_contact", f"cssrs:{day}", ctx.now)
if risk_task.status in ("pending", "active"):
await service.queue_task(ctx.session, ctx.token, "diary", f"diary:{day}", ctx.now)
return await start(ctx, "cssrs_since_contact", risk_task)
return await start(ctx, "diary")
@state("intake")
async def collect(ctx, event: Event):
if not allowed(ctx):
return Step(replies=[Reply(text=texts()["unavailable"], kind="info")], next_state="idle")
row = await ctx.session.get(QuestionnaireRecord, ctx.data.get("questionnaire_record", ""))
if row is None or row.token != ctx.token or row.status != "active":
return Step(next_state="idle")
if (
row.policy_version != load_policy().version
or row.version != content()["version"]
or row.details.get("runtime_version") != load_runtime().version
):
return await manual_review(ctx, row, "questionnaire_version_changed")
if event.type == "system":
task_id = event.params.get("questionnaire_task")
if task_id and row.details.get("task_id") == task_id:
return await advance(ctx, row)
return Step()
if event.button == "intake:pause":
row.status = "paused"
return Step(
replies=[Reply(text=texts()["paused"], buttons=[[Button(label=texts()["resume"], data="intake:resume")]])],
next_state="idle",
)
q = next_question(row)
if q is None:
return await advance(ctx, row)
prefix = f"iq:{row.id[:12]}:{q.id}:"
value = None
valid = False
if event.type == "button" and (event.button or "").startswith(prefix):
index = event.button[len(prefix) :]
if index == "skip" and q.optional:
valid = True
elif index.isdigit() and int(index) < len(q.options):
value, valid = q.options[int(index)][0], True
elif event.type == "text" and q.numeric:
try:
value = float(str(ctx.text).replace(",", "."))
valid = math.isfinite(value) and 0 <= value <= 24
except ValueError:
pass
elif event.type == "text" and q.optional:
value, valid = str(ctx.text)[:2000], True
if not valid:
return Step(replies=[Reply(text=texts()["numeric" if q.numeric else "invalid"]), question_reply(row, q)])
service.save_answer(row, q, value, ctx.now)
return await advance(ctx, row)
+183
View File
@@ -0,0 +1,183 @@
"""FSM framework of the dialog engine: context, step result and the state / trigger registries.
States are plain strings, namespaced by module ("veteran.onboarding.pcl5"); core states have short names
("new", "consent", "age_gate", "idle", "crisis", "consent_revoked", ...). A flow module
(`app/dialog/flows/<module>.py`) registers its states at import time — the engine never has to be edited to
add a scenario:
from app.dialog.fsm import Ctx, Step, on_enter, state
from app.dialog.scale_session import start_scale
@on_enter("veteran.onboarding.start") # runs when the FSM *enters* the state
async def _start(ctx: Ctx) -> Step:
return await start_scale(ctx, "pcptsd5", return_state="veteran.onboarding.pcl5")
@state("veteran.onboarding.trauma") # handles events while the FSM *is in* the state
async def _trauma(ctx: Ctx, event: Event) -> Step:
...
return Step(replies=[...], next_state="idle", data={"trauma": True})
Name resolution: an exact registration wins, otherwise the longest registered dotted prefix handles the state
("scale.phq9" → the generic "scale" handler). Triggers (`@trigger("button:menu:checkin")`,
`@trigger("system:daily_checkin")`, `@trigger("text:idle")`) let flows take over what the core `idle` state does
with menu buttons, system events and free text; the core registers defaults, a flow overrides with
`replace=True`.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.types import ScrubbedText
from app.db import repo
from app.db.clinical import PatientProfile
from app.dialog.contracts import Event, Reply
from app.dialog.texts import DialogTexts
from app.llm.gateway import LLMGateway
from app.services.contracts import MonitoringService
# States where nothing is being asked: a pending scale may start here (see engine `_start_pending`).
RESTING_STATES = frozenset({"idle", "crisis"})
# States without (valid) consent: the engine stores nothing and never calls the LLM here (152-FZ art. 9-10).
NO_CONSENT_STATES = frozenset({"new", "consent", "refused", "minor", "consent_revoked"})
@dataclass
class Ctx:
"""Everything a state handler may use. `data` is the persisted `dialog_state.data` (treat as read-only:
return changes in `Step.data`, the engine applies them)."""
token: str
state: str
data: dict[str, Any]
now: datetime # from app.core.clock, taken once per incoming event (ADR-005)
session: AsyncSession # clinical DB unit of work of this event
gateway: LLMGateway
monitoring: MonitoringService
texts: DialogTexts
text: ScrubbedText | None = None # scrubbed patient text for text events — the only text handlers see
_profile: PatientProfile | None = field(default=None, repr=False)
_profile_loaded: bool = field(default=False, repr=False)
async def profile(self) -> PatientProfile | None:
if not self._profile_loaded:
self._profile = await repo.get_profile(self.session, self.token)
self._profile_loaded = True
return self._profile
@dataclass
class Step:
"""Result of a handler.
replies — messages to send, in order;
next_state — new FSM state (None = stay); entering a different state runs its `@on_enter` hook;
data — updates merged into `dialog_state.data` (a value of None deletes the key);
notify_monitoring — the handler wrote clinical data: the engine commits and calls
`MonitoringService.after_data_change()`, then executes the returned dialog actions.
"""
replies: list[Reply] = field(default_factory=list)
next_state: str | None = None
data: dict[str, Any] = field(default_factory=dict)
notify_monitoring: bool = False
def then(self, other: Step) -> Step:
"""Sequential composition: replies concatenated, later state/data win."""
return Step(
replies=[*self.replies, *other.replies],
next_state=other.next_state or self.next_state,
data={**self.data, **other.data},
notify_monitoring=self.notify_monitoring or other.notify_monitoring,
)
Handler = Callable[[Ctx, Event], Awaitable[Step]]
EnterHook = Callable[[Ctx], Awaitable[Step]]
_handlers: dict[str, Handler] = {}
_enter_hooks: dict[str, EnterHook] = {}
_triggers: dict[str, Handler] = {}
def state(name: str) -> Callable[[Handler], Handler]:
"""Register the event handler of state `name` (or of a dotted prefix, e.g. "scale")."""
def deco(fn: Handler) -> Handler:
if name in _handlers:
raise ValueError(f"state '{name}' already registered")
_handlers[name] = fn
return fn
return deco
def on_enter(name: str) -> Callable[[EnterHook], EnterHook]:
"""Register a hook that runs when the FSM enters state `name` (asks the first question, chains flows)."""
def deco(fn: EnterHook) -> EnterHook:
if name in _enter_hooks:
raise ValueError(f"enter hook of '{name}' already registered")
_enter_hooks[name] = fn
return fn
return deco
def trigger(key: str, *, replace: bool = False) -> Callable[[Handler], Handler]:
"""Register a handler for an idle-menu button / system event / idle free text.
Keys: "button:<Button.data>", "system:<SystemEventKind>", "text:idle". The core registers defaults;
flows pass `replace=True` to take one over.
"""
def deco(fn: Handler) -> Handler:
if key in _triggers and not replace:
raise ValueError(f"trigger '{key}' already registered (use replace=True to override)")
_triggers[key] = fn
return fn
return deco
def _resolve(name: str, table: dict[str, Any]) -> str | None:
candidate = name
while candidate:
if candidate in table:
return candidate
if "." not in candidate:
return None
candidate = candidate.rsplit(".", 1)[0]
return None
def handler_for(name: str) -> Handler | None:
key = _resolve(name, _handlers)
return _handlers[key] if key else None
def enter_hook_for(name: str) -> EnterHook | None:
key = _resolve(name, _enter_hooks)
return _enter_hooks[key] if key else None
def get_trigger(key: str) -> Handler | None:
return _triggers.get(key)
def is_registered(name: str) -> bool:
"""Exact registration (handler or enter hook) — used to find a module's onboarding entry point."""
return name in _handlers or name in _enter_hooks
def apply_data(data: dict[str, Any], updates: dict[str, Any]) -> None:
for key, value in updates.items():
if value is None:
data.pop(key, None)
else:
data[key] = value
+236
View File
@@ -0,0 +1,236 @@
"""Reusable scale session: asks a questionnaire item by item inside the dialog (SPEC §6 "Сбор шкалы в диалоге").
Usage from a flow (#36/#37):
step = await start_scale(ctx, "pcl5", return_state="veteran.onboarding.phq9", scheduled=True)
return step # FSM goes to "scale.pcl5"; its enter hook asks item 1; on completion → return_state
Algorithm per item:
1. The item is shown with one button per answer option from `config/scales/<id>.yaml`.
2. A button press is taken as is (source="button").
3. Free text is accepted only for items without `buttons_only` (PHQ-9 item 9 and the whole C-SSRS are
buttons-only: suicidal-ideation answers are never interpreted by the LLM — SPEC §6, ADR-002). The scrubbed
text goes to `gateway.extract_item`; `None` (failure / confidence < 0.5 / out-of-range score) → a clarifying
question with the same buttons, nothing is stored (source="llm" only for a confident extraction).
4. Every answer is stored immediately (`item_responses`), so a session interrupted by a crisis keeps its data.
5. After each answer the scale is scored deterministically (`app.core.scales.score`). If a crisis flag from
`config/texts/dialog.yaml: settings.crisis_flags` fires (PHQ-9 item 9 ≥ 1, C-SSRS high) the session ends at
once: partial result saved, fixed crisis script, C-SSRS queued (unless this *is* the C-SSRS). A positive
item 9 is acted upon even in a half-finished questionnaire.
6. On completion: `scale_results` row (total, severity, item 9, scheduled flag, flags) → the engine commits
and calls `MonitoringService.after_data_change()` (rules decide alerts — ADR-007) → back to `return_state`.
The patient never sees a score or a severity label: that would read as a diagnosis.
"""
from typing import Any
from app.core.privacy import scrub
from app.core.scales import Item, ScaleDef, ScaleResult, load_scale, score
from app.db import repo
from app.dialog.contracts import Button, Event, Reply
from app.dialog.crisis import crisis_open, open_crisis
from app.dialog.fsm import Ctx, Step, on_enter, state
PREFIX = "scale"
def scale_state(scale_id: str) -> str:
return f"{PREFIX}.{scale_id}"
def active_scale(state_name: str) -> str | None:
return state_name.split(".", 1)[1] if state_name.startswith(PREFIX + ".") else None
def add_pending(data: dict[str, Any], scale_id: str, scheduled: bool) -> list[dict[str, Any]]:
"""Queue a scale to start at the next resting state (idle/crisis); duplicates are ignored."""
pending = list(data.get("pending_scales") or [])
if scale_id not in {p["scale"] for p in pending}:
pending.append({"scale": scale_id, "scheduled": scheduled})
return pending
async def start_scale(
ctx: Ctx, scale_id: str, *, return_state: str, scheduled: bool = True, intro: str | None = None
) -> Step:
"""Begin a session of `scale_id`; returns a Step moving the FSM to `scale.<id>`.
`scheduled=False` marks an unscheduled administration (triggered by a rule or a safety signal).
`intro` — optional extra text shown before the scale instruction.
"""
scale = load_scale(scale_id) # fail fast on an unknown id
sessions = dict(ctx.data.get("scales") or {})
sessions[scale_id] = {
"idx": 0,
"answers": {},
"return_state": return_state,
"scheduled": scheduled,
"intro": intro,
"intro_shown": False,
}
target = scale_state(scale_id)
if ctx.state == target: # restart in place: the enter hook would not run
sessions[scale_id]["intro_shown"] = True
return Step(replies=[_question(ctx, scale, sessions[scale_id], intro=True)], data={"scales": sessions})
return Step(next_state=target, data={"scales": sessions})
def _question(ctx: Ctx, scale: ScaleDef, session: dict[str, Any], *, intro: bool = False, note: str = "") -> Reply:
idx = session["idx"]
item = scale.items[idx]
parts: list[str] = []
if note:
parts.append(note)
if intro:
if session.get("intro"):
parts.append(session["intro"])
parts.append(ctx.texts.scale.intro.format(count=len(scale.items), instruction=scale.instruction or "").strip())
parts.append(ctx.texts.scale.question.format(n=idx + 1, total=len(scale.items), text=item.text))
buttons = [[Button(label=o.label, data=f"{PREFIX}:{scale.id}:{item.id}:{o.value}")] for o in item.options]
return Reply(text="\n\n".join(parts), buttons=buttons, kind="question")
def _session(ctx: Ctx) -> tuple[ScaleDef, dict[str, Any]] | None:
scale_id = active_scale(ctx.state)
session = (ctx.data.get("scales") or {}).get(scale_id) if scale_id else None
if scale_id is None or session is None:
return None
return load_scale(scale_id), session
@on_enter(PREFIX)
async def _enter(ctx: Ctx) -> Step:
found = _session(ctx)
if found is None:
return Step(next_state="idle")
scale, session = found
show_intro = session["idx"] == 0 and not session.get("intro_shown")
updated = {**session, "intro_shown": True}
sessions = {**(ctx.data.get("scales") or {}), scale.id: updated}
return Step(replies=[_question(ctx, scale, updated, intro=show_intro)], data={"scales": sessions})
def _parse_button(data: str, scale: ScaleDef, item: Item) -> int | None:
parts = data.split(":")
if len(parts) != 4 or parts[0] != PREFIX or parts[1] != scale.id or parts[2] != item.id:
return None # stale button from an earlier question
try:
value = int(parts[3])
except ValueError:
return None
return value if value in {o.value for o in item.options} else None
@state(PREFIX)
async def _handle(ctx: Ctx, event: Event) -> Step:
found = _session(ctx)
if found is None:
return Step(next_state="idle")
scale, session = found
item = scale.items[session["idx"]]
if event.type == "button" and event.button:
value = _parse_button(event.button, scale, item)
if value is not None:
return await _record(ctx, scale, session, item, value, source="button")
return Step(replies=[_question(ctx, scale, session)])
if event.type == "text" and ctx.text is not None:
if item.buttons_only:
# Safety items are never LLM-interpreted: ask again for a button.
return Step(replies=[_question(ctx, scale, session, note=ctx.texts.scale.buttons_only)])
extracted = await ctx.gateway.extract_item(item.id, item.text, item.max_value, ctx.text)
if extracted is None or extracted.score not in {o.value for o in item.options}:
return Step(replies=[_question(ctx, scale, session, note=ctx.texts.scale.clarify)])
return await _record(
ctx,
scale,
session,
item,
extracted.score,
source="llm",
confidence=extracted.confidence,
evidence=extracted.evidence,
)
if event.type == "system":
return Step() # reminders etc. do not interrupt a questionnaire
return Step(replies=[_question(ctx, scale, session)])
async def _record(
ctx: Ctx,
scale: ScaleDef,
session: dict[str, Any],
item: Item,
value: int,
*,
source: str,
confidence: float | None = None,
evidence: str = "",
) -> Step:
await repo.add_item_response(
ctx.session,
token=ctx.token,
scale=scale.id,
item_id=item.id,
score=value,
source=source,
ts=ctx.now,
confidence=confidence,
# Evidence is a quote of already scrubbed text; scrub again so nothing unscrubbed can be stored.
evidence=scrub(evidence) if evidence else None,
)
answers = {**session["answers"], item.id: value}
profile = await ctx.profile()
sex = profile.sex if profile is not None and profile.sex in ("m", "f") else None
result = score(scale.id, answers, sex=sex) # type: ignore[arg-type]
crisis_flags = [f for f in result.flags if f in ctx.texts.settings.crisis_flags]
if crisis_flags or session["idx"] + 1 >= len(scale.items):
return await _finish(ctx, scale, session, answers, result, crisis_flags)
updated = {**session, "answers": answers, "idx": session["idx"] + 1}
sessions = {**(ctx.data.get("scales") or {}), scale.id: updated}
return Step(replies=[_question(ctx, scale, updated)], data={"scales": sessions})
async def _finish(
ctx: Ctx,
scale: ScaleDef,
session: dict[str, Any],
answers: dict[str, int],
result: ScaleResult,
crisis_flags: list[str],
) -> Step:
await repo.add_scale_result(
ctx.session,
token=ctx.token,
scale=scale.id,
total=result.total,
severity=result.severity_code,
complete=result.complete,
ts=ctx.now,
# PHQ-9 item 9 is stored separately: any value >= 1 escalates regardless of the total.
item9=answers.get("phq9_9") if scale.id == "phq9" else None,
scheduled=bool(session.get("scheduled", True)),
flags={f: True for f in result.flags},
)
sessions = {k: v for k, v in (ctx.data.get("scales") or {}).items() if k != scale.id}
done = Step(
replies=[Reply(text=ctx.texts.scale.done)],
next_state=session["return_state"],
data={"scales": sessions},
notify_monitoring=True,
)
if not crisis_flags:
return done
# Crisis path decided by the scale's own flags (deterministic), not by the LLM.
risk_screen = ctx.texts.settings.risk_screen_scale
if crisis_open(ctx.data):
crisis = Step(next_state="crisis")
else:
crisis = open_crisis(ctx, categories=[], source=f"scale:{scale.id}")
step = done.then(crisis)
if scale.id != risk_screen:
step.data["pending_scales"] = add_pending(ctx.data, risk_screen, scheduled=False)
return step
+121
View File
@@ -0,0 +1,121 @@
"""Patient-facing dialog texts from `config/texts/dialog.yaml` (ADR-008: texts live in config, not code)."""
from functools import lru_cache
from pathlib import Path
import yaml
from pydantic import BaseModel, ConfigDict, Field
from app.config import get_settings
class _Strict(BaseModel):
# A typo in a YAML key must fail at load time, not silently show an empty message to a patient.
model_config = ConfigDict(extra="forbid", frozen=True)
class DialogSettings(_Strict):
cssrs_cooldown_hours: float = 24
max_safety_events: int = 50
crisis_flags: list[str] = Field(default_factory=list)
risk_screen_scale: str = "cssrs_screen"
class Commands(_Strict):
revoke: list[str]
class ConsentTexts(_Strict):
version: str
text: str
accept: str
decline: str
refused: str
reconsider: str
code_invalid: str
class AgeGateTexts(_Strict):
question: str
adult: str
minor: str
unclear: str
minor_stop: str
class IdleButtons(_Strict):
checkin: str
med_question: str
contact_doctor: str
revoke: str
class IdleTexts(_Strict):
welcome: str
menu_prompt: str
buttons: IdleButtons
stub_checkin: str
stub_med_question: str
contact_doctor_ack: str
visit_stub: str
class RevokedTexts(_Strict):
confirm: str
class CrisisTexts(_Strict):
repeat: str
hold: str
closed: str
class ScaleTexts(_Strict):
intro: str
question: str
clarify: str
buttons_only: str
done: str
risk_screen_intro: str
class SafetyPlanTexts(_Strict):
title: str
template: list[str]
means_restriction: str
class ReachOutTexts(_Strict):
silence: str
missed_doses: str
default: str
class VisitTexts(_Strict):
button: str
class DialogTexts(_Strict):
status: str
settings: DialogSettings
commands: Commands
consent: ConsentTexts
age_gate: AgeGateTexts
idle: IdleTexts
revoked: RevokedTexts
crisis: CrisisTexts
scale: ScaleTexts
safety_plan: SafetyPlanTexts
reach_out: ReachOutTexts
visit: VisitTexts
unavailable: str
def dialog_texts_path() -> Path:
return get_settings().config_dir / "texts" / "dialog.yaml"
@lru_cache
def load_dialog_texts(path: Path | None = None) -> DialogTexts:
path = path or dialog_texts_path()
return DialogTexts.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
+170 -7
View File
@@ -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
+264
View File
@@ -0,0 +1,264 @@
"""Transactional questionnaire storage and a durable doctor notification queue.
Notifier acceptance is not delivery or a doctor's acknowledgement. At-least-once
retries can duplicate delivery if a process dies after sending but before commit.
"""
import asyncio
from dataclasses import asdict
from datetime import timedelta
from uuid import uuid4
from zoneinfo import ZoneInfo
from sqlalchemy import select
from app.core import clock
from app.core.intake_questions import content, cssrs_answers, group_answers
from app.core.intake_runtime import load_runtime, month_before
from app.core.questionnaire import evaluate_intake, load_policy, score_alcohol, score_cssrs_recent
from app.core.scales import load_scale, score
from app.core.types import AlertLevel
from app.db import repo
from app.db.clinical import Alert, DialogState
from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask
from app.db.session import get_clinical_session
from app.integrations.base import MemoryNotifier, integrations
async def records(session, token):
return list(
(
await session.scalars(
select(QuestionnaireRecord)
.where(QuestionnaireRecord.token == token)
.order_by(QuestionnaireRecord.started_at, QuestionnaireRecord.id)
)
).all()
)
async def create_record(ctx, kind, *, task=None):
profile = await ctx.profile()
runtime = load_runtime()
prior = next(
(r for r in reversed(await records(ctx.session, ctx.token)) if r.kind == "intake" and r.status == "complete"),
None,
)
sex = profile.sex if profile else None
if sex is None and prior:
sex = prior.details.get("sex")
row = QuestionnaireRecord(
id=uuid4().hex,
token=ctx.token,
kind=kind,
version=content()["version"],
policy_version=load_policy().version,
started_at=ctx.now,
day=ctx.now.astimezone(ZoneInfo(runtime.timezone)).date(),
status="active",
answers={},
details={
"runtime_version": runtime.version,
"modules": list(profile.modules or []) if profile else [],
"sex": sex,
"medication_active": bool(
(profile and profile.therapy_start and profile.therapy_start <= ctx.now.date())
or (prior and prior.answers.get("psychotropics") == 1)
),
"suicide_monitoring": bool(profile and profile.enhanced_monitoring),
"weekly_pcl": kind == "pcl5" and bool((ctx.data.get("crisis") or {}).get("closed_at")),
"answer_metadata": {},
"task_id": task.id if task else None,
},
)
ctx.session.add(row)
await ctx.session.flush()
if task:
task.record_id = row.id
task.status = "active"
return row
def save_answer(row, question, value, now):
"""Anchor recall windows to administration, never to the day of a resumed answer."""
day = row.day
starts = {
"past_month": month_before(day),
"past_3_months": month_before(day, 3),
"past_week": day - timedelta(days=7),
"past_day": day - timedelta(days=1),
"последние 2 недели": day - timedelta(days=14),
"последний месяц": month_before(day),
"последняя неделя": day - timedelta(days=7),
"прошлая неделя": day - timedelta(days=7),
"последние 12 месяцев": month_before(day, 12),
}
row.answers = {**row.answers, question.id: value}
if question.id == "sex" and value in ("m", "f"):
row.details = {**row.details, "sex": value}
row.details = {
**row.details,
"answer_metadata": {
**row.details.get("answer_metadata", {}),
question.id: {
"answered_at": now.isoformat(),
"period": question.period,
"period_start": starts[question.period].isoformat() if question.period in starts else None,
"period_end": day.isoformat(),
"source": "patient",
"derived": False,
},
},
}
def decision(row):
a = row.answers
sex = row.details.get("sex") or (a.get("sex") if a.get("sex") in ("m", "f") else None)
return evaluate_intake(
phq9=group_answers(a, "phq9"),
tsq=group_answers(a, "tsq"),
pcl5=group_answers(a, "pcl5"),
alcohol=group_answers(a, "rus_audit"),
cssrs=cssrs_answers(a),
sex=sex,
trauma_exposure=bool(a["trauma"]) if "trauma" in a else None,
traumatic_brain_injury=a.get("tbi") == 1,
psychotropics=a.get("psychotropics") == 1,
)
def summary(row):
a = row.answers
totals = {}
for sid in ("phq9", "tsq", "pcl5"):
result = score(sid, group_answers(a, sid))
totals[sid] = {
"complete": result.complete,
"total": result.total if result.complete else None,
"answered": result.answered,
}
sex = row.details.get("sex") or (a.get("sex") if a.get("sex") in ("m", "f") else None)
for sid in ("rus_audit_s", "rus_audit"):
values = {k: a[k] for k in load_scale(sid).item_ids if k in a}
totals[sid] = asdict(score_alcohol(values, short=sid.endswith("_s"), sex=sex))
totals["cssrs_recent"] = asdict(score_cssrs_recent(cssrs_answers(a)))
return totals
async def finish(ctx, row, status, *, extra=None):
row.status = status
row.completed_at = ctx.now if status == "complete" else None
row.details = {**row.details, **(extra or {})}
if row.details.get("task_id"):
task = await ctx.session.get(QuestionnaireTask, row.details["task_id"])
if task:
task.status = "done"
async def alert(session, token, key, code, now, *, level=AlertLevel.MODERATE, params=None):
existing = await session.scalar(
select(QuestionnaireNotice).where(QuestionnaireNotice.token == token, QuestionnaireNotice.event_key == key)
)
if existing:
return existing
row = await repo.add_alert(session, token=token, level=level, code=code, reason=code, ts=now, params=params)
notice = QuestionnaireNotice(token=token, event_key=key, alert_id=row.id, created_at=now)
session.add(notice)
await session.flush()
return notice
async def cancel_pending(session, token):
for row in await records(session, token):
if row.status in ("active", "paused"):
row.status = "cancelled"
tasks = await session.scalars(
select(QuestionnaireTask).where(
QuestionnaireTask.token == token, QuestionnaireTask.status.in_(("pending", "active"))
)
)
for task in tasks:
task.status = "cancelled"
task.delivery = []
notices = await session.scalars(
select(QuestionnaireNotice).where(QuestionnaireNotice.token == token, QuestionnaireNotice.status == "pending")
)
for notice in notices:
notice.status = "cancelled"
async def dispatch_notices(token=None):
now = clock.now()
async with get_clinical_session() as session:
stmt = (
select(QuestionnaireNotice.id, QuestionnaireNotice.token)
.where(QuestionnaireNotice.status == "pending")
.order_by(QuestionnaireNotice.id)
.limit(100)
)
if token:
stmt = stmt.where(QuestionnaireNotice.token == token)
candidates = (await session.execute(stmt)).all()
for notice_id, patient_token in candidates:
async with get_clinical_session() as session:
# Same lock order as withdrawal: patient first, then notice.
await lock_patient(session, patient_token)
notice = await session.scalar(
select(QuestionnaireNotice).where(QuestionnaireNotice.id == notice_id).with_for_update()
)
if notice.status != "pending":
continue
if notice.attempted_at and now < notice.attempted_at + timedelta(
minutes=min(60, 2 ** min(notice.attempts, 6))
):
continue
state = await repo.get_dialog_state(session, notice.token)
if state is None or state.data.get("consent") is not True:
notice.status = "cancelled"
continue
profile = await repo.get_profile(session, notice.token)
if not profile or not profile.doctor_id:
continue
item = await session.get(Alert, notice.alert_id)
notice.attempted_at, notice.attempts = now, notice.attempts + 1
try:
await asyncio.wait_for(
integrations.notifier.send(profile.doctor_id, item.level, item.code, token=notice.token), timeout=10
)
except Exception as exc:
notice.error_code = type(exc).__name__[:80]
else:
notice.error_code = None
notice.accepted_at = now
notice.status = (
"test_sink" if isinstance(integrations.notifier, MemoryNotifier) else "accepted_by_adapter"
)
async def queue_task(session, token, kind, key, now):
existing = await session.scalar(
select(QuestionnaireTask).where(QuestionnaireTask.token == token, QuestionnaireTask.task_key == key)
)
if existing:
return existing
# Only one outstanding administration per form, however many ticks were missed.
existing = await session.scalar(
select(QuestionnaireTask).where(
QuestionnaireTask.token == token,
QuestionnaireTask.kind == kind,
QuestionnaireTask.status.in_(("pending", "active")),
)
)
if existing:
return existing
row = QuestionnaireTask(token=token, kind=kind, task_key=key, due_at=now)
session.add(row)
await session.flush()
return row
async def lock_patient(session, token):
# PostgreSQL serializes callbacks from different workers. SQLite tests additionally
# use the engine's in-process lock. Consent and answers share this transaction.
await session.scalar(select(DialogState).where(DialogState.token == token).with_for_update())
+61
View File
@@ -0,0 +1,61 @@
# Verbatim source content; only whitespace/line wraps normalized. Not enabled in the legacy dialog.
id: cssrs_recent_ru
version: source-2026-10-03
title: КОЛУМБИЙСКАЯ ШКАЛА ОЦЕНКИ ТЯЖЕСТИ СУИЦИДА
subtitle: Screen Version - Recent
period_label_original: Последние 1 месяца
initial_instruction: Задайте вопросы 1 и 2
branch_instruction: ЕСЛИ ДА 2, Задайте вопросы 3, 4, 5 и 6. ЕСЛИ Нет, Задайте вопросы 6.
options:
- value: 1
label: Да
- value: 0
label: Нет
items:
- id: q1
text: Возникало ли у Вас желание умереть или заснуть и не просыпаться?
interviewer_guidance: null
recall_period: past_month
yes_risk: low
- id: q2
text: Думали ли Вы в самом деле о том, чтобы убить себя?
interviewer_guidance: null
recall_period: past_month
yes_risk: low
- id: q3
text: Думали ли Вы о том, как Вы могли бы это сделать?
interviewer_guidance: 'Пациент подтверждает, что его посещают мысли о самоубийстве и что в течение периода наблюдения
он/она думал(-а) хотя бы об одном способе самоубийства. Данная ситуация отлична от такой, когда пациент разработал
конкретный план: время, место или детали метода самоубийства (например, пациент думал о способе самоубийства,
но не о конкретном плане). Сюда относятся люди, которые скажут: “Я думал(-а) о передозировке таблеток, но так
и не построил(-а) конкретного плана о том, когда, где и как осуществить это…..и я бы никогда не довёл(-а) дело
до конца”.'
recall_period: past_month
yes_risk: moderate
- id: q4
text: Посещали ли Вас подобные мысли, и было ли у Вас какое-нибудь намерение действовать в соответствии с ними?
interviewer_guidance: 'Активные мысли о самоубийстве при заявлении пациента о некотором намерении действовать
в соответствии с этими мыслями, в отличие от подобного утверждения: “Такие мысли посещают меня, но я определенно
не стану ничего предпринимать”.'
recall_period: past_month
yes_risk: high
- id: q5
text: Начали ли Вы разрабатывать или уже разработали детальный план самоубийства? Намереваетесь ли Вы осуществить
этот план?
interviewer_guidance: null
recall_period: past_month
yes_risk: high
- id: q6_lifetime
text: Вы когда-нибудь что-нибудь делали, начинали что-то делать или готовились сделать что-нибудь, чтобы покончить
с собой?
interviewer_guidance: 'Примеры: запаслись таблетками, приобрели пистолет, раздали ценные вещи, написали завещание
или предсмертную записку, запаслись таблетками, но не проглотили их, держали пистолет, но передумали стрелять
или его кто-то выхватил из вашей руки, поднялись на крышу, но не прыгнули; или принимали таблетки, пытались
застрелиться, порезались, пытался повеситься и т. д.'
recall_period: lifetime
yes_risk: moderate
- id: q6_recent
text: ЕСЛИ ДА, то было ли это в последние 3 месяца?
interviewer_guidance: null
recall_period: past_3_months
yes_risk: high
+189
View File
@@ -0,0 +1,189 @@
version: diary-v2-2026-10-03
core:
- id: mood
text: 'Какое у Вас сегодня настроение?
0 — самое плохое, 10 — самое хорошее.'
options:
- value: 0
label: '0'
- value: 1
label: '1'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- value: 5
label: '5'
- value: 6
label: '6'
- value: 7
label: '7'
- value: 8
label: '8'
- value: 9
label: '9'
- value: 10
label: '10'
- id: sleep_hours
text: Сколько часов Вы спали прошлой ночью?
numeric: true
- id: rested
text: 'Насколько Вы выспались?
0 — совсем не выспались, 4 — полностью выспались.'
options:
- value: 0
label: '0'
- value: 1
label: '1'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- id: medication
text: Приняли ли Вы сегодня лекарства по схеме?
options:
- value: full
label: По схеме
- value: partial
label: Частично
- value: none
label: Нет
- value: not_prescribed
label: Не назначены
- id: medication_reason
text: Почему не приняли лекарства по схеме?
options:
- value: forgot
label: Забыл(а)
- value: side_effects
label: Побочные эффекты
- value: declined
label: Не хочу
- value: ran_out
label: Закончились
- value: other
label: Другая причина
- id: side_effects
text: Беспокоили ли Вас сегодня побочные эффекты лекарств?
options: &id001
- value: 1
label: Да
- value: 0
label: Нет
- id: substances
text: Употребляли ли Вы сегодня алкоголь или другие вещества?
options: *id001
- id: comment
text: Что хотите передать врачу?
optional: true
ptsd:
- id: intrusions
text: 'Насколько за последние сутки Вас беспокоили: нежелательные воспоминания о событии?'
options: &id002
- value: 0
label: Совсем нет
- value: 1
label: Немного
- value: 2
label: Умеренно
- value: 3
label: Сильно
- value: 4
label: Крайне сильно
- id: nightmares
text: 'Насколько за последние сутки Вас беспокоили: кошмары прошлой ночью?'
options: *id002
- id: avoidance
text: 'Насколько за последние сутки Вас беспокоили: желание избегать мест, людей и мыслей, напоминающих о событии?'
options: *id002
- id: hypervigilance
text: 'Насколько за последние сутки Вас беспокоили: настороженность, вздрагивание, ощущение опасности?'
options: *id002
- id: irritability
text: 'Насколько за последние сутки Вас беспокоили: раздражительность или вспышки гнева?'
options: *id002
depression:
- id: interest
text: 'Насколько Вам сегодня было интересно и приятно что-то делать?
0 — совсем нет, 4 — крайне сильно.'
options: *id002
- id: depressed
text: Насколько Вы сегодня чувствовали подавленность или безнадёжность?
options: *id002
- id: energy
text: 'Насколько у Вас сегодня были силы и энергия?
0 — совсем нет, 4 — крайне сильно.'
options: *id002
- id: planned_activity
text: Сделали ли Вы сегодня хотя бы одно запланированное дело?
options: *id001
suicide:
- id: death_thoughts
text: Были ли у Вас сегодня мысли о смерти или о том, что не хочется жить?
options: *id001
- id: thoughts_intensity
text: 'Если да — насколько сильными они были?
0 — совсем слабые, 10 — максимально сильные.'
options:
- value: 0
label: '0'
- value: 1
label: '1'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- value: 5
label: '5'
- value: 6
label: '6'
- value: 7
label: '7'
- value: 8
label: '8'
- value: 9
label: '9'
- value: 10
label: '10'
- id: hope
text: 'Насколько Вы верите, что станет лучше?
0 — совсем не верю, 10 — полностью верю.'
options:
- value: 0
label: '0'
- value: 1
label: '1'
- value: 2
label: '2'
- value: 3
label: '3'
- value: 4
label: '4'
- value: 5
label: '5'
- value: 6
label: '6'
- value: 7
label: '7'
- value: 8
label: '8'
- value: 9
label: '9'
- value: 10
label: '10'
- id: burden
text: Чувствовали ли Вы себя обузой для близких?
options: *id001
weekly: true
+167
View File
@@ -0,0 +1,167 @@
version: mvp-v2-2026-10-03
forms:
phq9:
id: phq9
title: Опросник здоровья пациента (PHQ-9)
recall_period: последние 2 недели
instruction: Как часто за последние 2 недели Вас беспокоили следующие проблемы?
license_note: PHQ-9 (Pfizer) — свободное использование, разрешения не требуется.
translation_note: Стандартная русская версия PHQ-9 (MAPI). Финальную формулировку утверждает психиатр.
options:
- value: 0
label: Ни разу
- value: 1
label: Несколько дней
- value: 2
label: Более недели
- value: 3
label: Почти каждый день
items:
- id: phq9_1
text: Вам не хотелось ничего делать
- id: phq9_2
text: У Вас было плохое настроение, Вы были подавлены или испытывали чувство безысходности
- id: phq9_3
text: Вам было трудно заснуть, у Вас был прерывистый сон, или Вы слишком много спали
- id: phq9_4
text: Вы были утомлены, или у Вас было мало сил
- id: phq9_5
text: У Вас был плохой аппетит, или Вы переедали
- id: phq9_6
text: 'Вы плохо о себе думали: считали себя неудачником (неудачницей), или были в себе разочарованы, или считали,
что подвели свою семью'
- id: phq9_7
text: Вам было трудно сосредоточиться (например, на чтении газеты или на просмотре телепередач)
- id: phq9_8
text: Вы двигались или говорили настолько медленно, что окружающие это замечали? Или, наоборот, Вы были настолько
суетливы или взбудоражены, что двигались гораздо больше обычного
- id: phq9_9
text: Вас посещали мысли о том, что Вам лучше было бы умереть, или о том, чтобы причинить себе какой-нибудь
вред
buttons_only: true
range:
min: 0
max: 27
severity:
- min: 0
max: 4
code: minimal
label: Минимальная
- min: 5
max: 9
code: mild
label: Лёгкая
- min: 10
max: 14
code: moderate
label: Умеренная
- min: 15
max: 19
code: moderately_severe
label: Умеренно-тяжёлая
- min: 20
max: 27
code: severe
label: Тяжёлая
flags:
- code: phq9_item9_positive
when:
item: phq9_9
op: '>='
value: 1
pcl5:
id: pcl5
title: Контрольный список симптомов ПТСР (PCL-5)
recall_period: последний месяц
instruction: 'Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового
события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант,
который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц.
Насколько сильно за последний месяц Вас беспокоили:'
license_note: PCL-5 — общественное достояние (VA National Center for PTSD); русская версия — лицензия на проверке.
translation_note: Рабочий перевод команды (демо-текст). Финальный русский перевод выбирает психиатр (SPEC §9).
Для еженедельного мониторинга используется та же шкала с периодом «последняя неделя».
options:
- value: 0
label: Совсем нет
- value: 1
label: Немного
- value: 2
label: Умеренно
- value: 3
label: Сильно
- value: 4
label: Крайне сильно
items:
- id: pcl5_1
text: Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?
- id: pcl5_2
text: Повторяющиеся тяжёлые сны о стрессовом событии?
- id: pcl5_3
text: Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно
вернулись туда и переживаете его заново)?
- id: pcl5_4
text: Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?
- id: pcl5_5
text: Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение,
затруднённое дыхание, потливость)?
- id: pcl5_6
text: Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?
- id: pcl5_7
text: Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов
или ситуаций)?
- id: pcl5_8
text: Трудности с тем, чтобы вспомнить важные части стрессового события?
- id: pcl5_9
text: Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то
серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?
- id: pcl5_10
text: Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?
- id: pcl5_11
text: Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?
- id: pcl5_12
text: Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?
- id: pcl5_13
text: Ощущение отдалённости или оторванности от других людей?
- id: pcl5_14
text: Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость
или любовь к близким людям)?
- id: pcl5_15
text: Раздражительность, вспышки гнева или агрессивное поведение?
- id: pcl5_16
text: Чрезмерный риск или поступки, которые могут причинить Вам вред?
- id: pcl5_17
text: Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?
- id: pcl5_18
text: Взвинченность или склонность легко вздрагивать?
- id: pcl5_19
text: Трудности с концентрацией внимания?
- id: pcl5_20
text: Трудности с засыпанием или прерывистый сон?
range:
min: 0
max: 80
severity:
- min: 0
max: 32
code: below_threshold
label: Ниже порога ПТСР
- min: 33
max: 49
code: probable
label: Вероятное ПТСР
- min: 50
max: 80
code: high
label: Выраженная симптоматика ПТСР
flags:
- code: pcl5_probable_ptsd
when:
total: true
op: '>='
value: 33
trauma_introduction: 'Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или
травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение
или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства
или самоубийства.'
trauma_filter: Случалось ли с Вами подобное событие?
+26
View File
@@ -0,0 +1,26 @@
version: intake-v2-2026-10-03
enabled: false
approval: null
# Release decisions cover texts, diary anchors/thresholds, schedules and contact readiness.
content_review: null
contact_review: null
timezone: Europe/Moscow
daily_hour: 19
mood_baseline_days: 7
mood_bad_days: 3
mood_drop: 3
sleep_bad_days: 3
sleep_hours_below: 4
nightmares_at: 3
missed_medication_days: 3
high_risk_silence_days: 2
medication_days: [7, 14, 28]
medication_repeat_days: 28
route_intervals:
after_crisis: {phq9: 7, pcl5: 14}
specialist: {phq9: 14, pcl5: 30}
self_help: {phq9: 28, pcl5: 28}
alcohol_repeat_days: 30
phq9_retriage_delta: 5
pcl5_retriage_delta: 10
+13
View File
@@ -0,0 +1,13 @@
# Review candidate from the supplied MVP PDF, not a signed clinical protocol.
version: mvp-v2-review-2026-10-03
approval: null
cssrs_positive_policy: pending
rus_audit_s_expand_from_zone: null
phq2_expand_at: 2
tsq_expand_at: 6
phq9_psychologist_at: 5
phq9_specialist_at: 10
phq9_priority_at: 20
pcl5_specialist_at: 31
pcl5_priority_at: 50
pcl5_behavior_flag_at: 3
+58
View File
@@ -0,0 +1,58 @@
id: rus_audit
title: RUS-AUDIT
recall_period: "Последние 12 месяцев; п.9 и п.10 также учитывают более ранние события"
license_note: "RUS-AUDIT: © ВОЗ, 2021, CC BY-NC-SA 3.0 IGO; некоммерческое использование. См. docs/clinical/README.md."
translation_note: "Текст из предоставленного PDF «Опросники для MVP», с.7–9. Самозаполнение полной интервью-формы не утверждено."
notes: "Пункт 2 требует таблицы стандартных порций из исходного бланка. Не заменять произвольным калькулятором. Автопереходы не утверждены."
options:
- {value: 0, label: "Никогда"}
- {value: 1, label: "Реже одного раза в месяц"}
- {value: 2, label: "Ежемесячно"}
- {value: 3, label: "Еженедельно"}
- {value: 4, label: "Ежедневно или почти ежедневно"}
items:
- id: rus_audit_1
text: "Как часто Вы употребляете алкогольные напитки?"
options:
- {value: 0, label: "Никогда"}
- {value: 1, label: "Раз в месяц или реже"}
- {value: 2, label: "2–4 раза в месяц"}
- {value: 3, label: "2–3 раза в неделю"}
- {value: 4, label: "4 раза в неделю и чаще"}
- id: rus_audit_2
text: "Сколько алкогольных напитков (стандартных порций) Вы употребляете в типичный день, когда выпиваете? Если в типичный день Вы употребляете несколько разных алкогольных напитков, то суммируйте число СП."
options:
- {value: 0, label: "1–2"}
- {value: 1, label: "3–4"}
- {value: 2, label: "5–6"}
- {value: 3, label: "7–9"}
- {value: 4, label: "10 и больше"}
- {id: rus_audit_3, text: "Как часто Вы употребляете как минимум 1,5 л пива, или как минимум 180 мл крепкого алкоголя, или как минимум бутылку вина или шампанского (750 мл) в течение 24 часов?"}
- {id: rus_audit_4, text: "Как часто за последние 12 месяцев Вы не могли остановиться, начав употреблять алкогольные напитки?"}
- {id: rus_audit_5, text: "Как часто за последние 12 месяцев из-за выпивки Вы не сделали то, что от Вас обычно ожидалось?"}
- {id: rus_audit_6, text: "Как часто за последние 12 месяцев Вам необходимо было выпить утром, чтобы прийти в себя после выпивки (опохмелиться)?"}
- {id: rus_audit_7, text: "Как часто за последние 12 месяцев Вы испытывали чувство вины или сожаления после выпивки?"}
- {id: rus_audit_8, text: "Как часто за последние 12 месяцев Вы были неспособны вспомнить, что было накануне, из-за того, что Вы выпивали?"}
- id: rus_audit_9
text: "Являлось ли Ваше употребление алкогольных напитков причиной травмы у Вас или у других людей?"
options: &history
- {value: 0, label: "Никогда"}
- {value: 2, label: "Да, более 12 месяцев назад"}
- {value: 4, label: "Да, в течение последних 12 месяцев"}
- id: rus_audit_10
text: "Случалось ли, что Ваш близкий человек или родственник, друг или врач беспокоился насчёт употребления Вами алкоголя или советовал выпивать меньше?"
options: *history
range: {min: 0, max: 40}
severity:
- {min: 0, max: 40, code: unclassified, label: "Не выбран пол для интерпретации"}
by_sex:
f:
- {min: 0, max: 4, code: zone_1, label: "Зона I"}
- {min: 5, max: 9, code: zone_2, label: "Зона II"}
- {min: 10, max: 10, code: zone_3, label: "Зона III"}
- {min: 11, max: 40, code: zone_4, label: "Зона IV"}
m:
- {min: 0, max: 8, code: zone_1, label: "Зона I"}
- {min: 9, max: 13, code: zone_2, label: "Зона II"}
- {min: 14, max: 16, code: zone_3, label: "Зона III"}
- {min: 17, max: 40, code: zone_4, label: "Зона IV"}
+41
View File
@@ -0,0 +1,41 @@
id: rus_audit_s
title: RUS-AUDIT-S
recall_period: "Последние 12 месяцев; п.9 и п.10 также учитывают более ранние события"
license_note: "RUS-AUDIT: © ВОЗ, 2021, CC BY-NC-SA 3.0 IGO; некоммерческое использование. См. docs/clinical/README.md."
translation_note: "Транскрипция рис.3 Буновой и соавт., 2022, DOI 10.17116/profmed20222508121; визуально сверена 2026-10-03. Интеграция не утверждена."
notes: "Пункты 3,9,10 полной шкалы. Зоны не задают автоматический переход к полной шкале. Не применять пороги AUDIT-C."
items:
- id: rus_audit_3
text: "Как часто Вы употребляете как минимум 1,5 литра пива, или как минимум 180 мл крепкого алкоголя, или как минимум бутылку вина или шампанского (750 мл) в течение 24 часов?"
options:
- {value: 0, label: "Никогда"}
- {value: 1, label: "Реже одного раза в месяц"}
- {value: 2, label: "Ежемесячно"}
- {value: 3, label: "Еженедельно"}
- {value: 4, label: "Ежедневно или почти ежедневно"}
- id: rus_audit_9
text: |-
Являлось ли Ваше употребление алкогольных напитков причиной травмы* у Вас или у других людей?
*Повреждение организма, вызванное внешним воздействием (ранением, ушибом, ожогом и т.д.).
options: &history
- {value: 0, label: "Никогда"}
- {value: 2, label: "Да, более 12 месяцев назад"}
- {value: 4, label: "Да, в течение последних 12 месяцев"}
- id: rus_audit_10
text: "Случалось ли, что Ваш близкий человек или родственник, друг или врач беспокоился насчет употребления Вами алкоголя или советовал выпивать меньше?"
options: *history
range: {min: 0, max: 12}
severity:
- {min: 0, max: 12, code: unclassified, label: "Не выбран пол для интерпретации"}
by_sex:
f:
- {min: 0, max: 1, code: zone_1, label: "Зона I"}
- {min: 2, max: 4, code: zone_2, label: "Зона II"}
- {min: 5, max: 5, code: zone_3, label: "Зона III"}
- {min: 6, max: 12, code: zone_4, label: "Зона IV"}
m:
- {min: 0, max: 3, code: zone_1, label: "Зона I"}
- {min: 4, max: 6, code: zone_2, label: "Зона II"}
- {min: 7, max: 7, code: zone_3, label: "Зона III"}
- {min: 8, max: 12, code: zone_4, label: "Зона IV"}
+59
View File
@@ -0,0 +1,59 @@
id: tsq
title: Опросник на скрининг ПТСР (TSQ)
recall_period: прошлая неделя
instruction: Эта анкета связана с вашими личными реакциями на травматическое событие, которое случилось с вами.
Ниже указаны некоторые реакции, которые иногда возникают у людей после травматического события. Пожалуйста, ответьте
«Да», если вы испытывали следующие симптомы по крайней мере дважды на прошлой неделе.
translation_note: КР ПТСР 2023, ID 753, приложение Г1, с.129–130 PDF; копия emdr.ru. Опечатки сохранены. Актуальность
редакции не подтверждена.
options:
- value: 1
label: ДА
- value: 0
label: НЕТ
items:
- id: tsq_1
text: Тяжелые мысли или воспоминания о событии приходили мне в голову против моей воли
buttons_only: true
- id: tsq_2
text: Мне снились тяжелые сны о том, что со мной случилось
buttons_only: true
- id: tsq_3
text: Я вдруг замечал(а), что действую и чувствую себя так, как будто бы ситуация повторяется снова
buttons_only: true
- id: tsq_4
text: Когда что-то напоминает мне об этом событии, я чувствую себя подавленным
buttons_only: true
- id: tsq_5
text: Когда что-то напоминало мне о случившемся, я испытывал(а) неприятные физические ощущения (потливость, сбой
дыхания, тошноту, учащение пульса и др.)
buttons_only: true
- id: tsq_6
text: У меня нарушен сон (трудности засыпания или частые пробуждения)
buttons_only: true
- id: tsq_7
text: Я чувствовал(а) постоянное раздражение и гнев
buttons_only: true
- id: tsq_8
text: Мне было сложно сосредоточится
buttons_only: true
- id: tsq_9
text: Я стал более осведомлён о потенциальных опасностях для себя и других
buttons_only: true
- id: tsq_10
text: Я все время был(а) напряжен(а) и вздрагивал(а), если что-то внезапно пугало меня
buttons_only: true
range:
min: 0
max: 10
severity:
- min: 0
max: 5
code: negative
label: Ниже порога
- min: 6
max: 10
code: positive
label: Требуется уточнение
license_note: "Текст приложения Г1 клинических рекомендаций 2023 года; условия использования и актуальную редакцию подтвердить до включения."
+8 -8
View File
@@ -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."
+114
View File
@@ -0,0 +1,114 @@
# Тексты диалогового движка (issue #10): согласие, возраст, меню, кризис, шкалы, отзыв согласия.
# СТАТУС: черновик, утверждает психиатр (#8).
#
# Правила текстов: без диагнозов и названий расстройств; без советов по дозам; не обещать конфиденциальность
# сверх реальной; не использовать слова «учёт», «контроль», «психиатрический надзор» (проверяется тестом).
# Кризисный скрипт — в crisis.yaml (отправляется дословно, LLM не вызывается).
status: "черновик, утверждает психиатр (#8)"
settings:
# Повторный C-SSRS по сигналу POSSIBLE не чаще, чем раз в N часов (сигнал всё равно пишется в safety_events).
cssrs_cooldown_hours: 24
# Сколько последних safety-событий хранить в dialog_state.data["safety_events"].
max_safety_events: 50
# Флаги шкал (config/scales/*.yaml), при которых сессия шкалы сразу уходит в кризисный путь.
crisis_flags: [phq9_item9_positive, cssrs_high]
# Шкала-скрининг суицидального риска (POSSIBLE и положительный п.9 PHQ-9 → эта шкала).
risk_screen_scale: cssrs_screen
commands:
# Текстовые команды отзыва согласия (сравниваются после приведения к нижнему регистру).
revoke: ["/revoke", "отозвать согласие", "отзываю согласие"]
consent:
version: "v1-draft-2026-10-03"
text: |-
Здравствуйте! Я — цифровой помощник, который сопровождает вас между визитами к врачу: напоминаю о приёме препарата, спрашиваю о самочувствии и передаю важное вашему лечащему врачу.
Прежде чем начать, мне нужно ваше согласие на обработку персональных данных, в том числе сведений о здоровье (152-ФЗ «О персональных данных», ст. 9 и 10).
• Что обрабатывается: ваши ответы о самочувствии, сне, приёме препарата и результаты коротких опросников. Перед автоматической обработкой из сообщений удаляются имена, телефоны и другие личные данные.
• Кто видит данные: только ваш лечащий врач. Данные хранятся в России и не передаются третьим лицам без вашего согласия, кроме случаев, прямо предусмотренных законом.
• Я не ставлю диагнозов и не заменяю врача. Решения о лечении принимает только врач.
• Участие добровольное. Отказ никак не влияет на вашу медицинскую помощь.
• Отозвать согласие можно в любой момент в один шаг: кнопкой «Отозвать согласие» или командой /revoke.
Я не служба экстренной помощи. Если вам угрожает опасность прямо сейчас — звоните 112.
accept: "Согласен(на)"
decline: "Не согласен(на)"
refused: |-
Понимаю, это ваше право. Без согласия я не обрабатываю и не сохраняю ваши сообщения. Ваше лечение у врача продолжается как обычно.
Если передумаете — нажмите кнопку ниже или отправьте /start.
reconsider: "Я передумал(а)"
code_invalid: "Код подключения не найден или уже использован. Пожалуйста, уточните код у вашего врача и отправьте его ещё раз."
age_gate:
question: "Уточните, пожалуйста: вам уже исполнилось 18 лет? Можно нажать кнопку или написать год рождения (например, 1990)."
adult: "Да, мне 18 или больше"
minor: "Нет, мне меньше 18"
unclear: "Не удалось понять ответ. Нажмите кнопку или напишите год рождения четырьмя цифрами, например 1990."
minor_stop: |-
Спасибо, что ответили. Этот помощник рассчитан на взрослых, поэтому дальше я не смогу с вами работать и не сохраняю ваши ответы.
Если вам тяжело или нужна поддержка — позвоните на Детский телефон доверия 8-800-2000-122 (бесплатно, анонимно, круглосуточно) или расскажите взрослому, которому доверяете: родителям, учителю, школьному психологу.
Если есть угроза жизни прямо сейчас — звоните 112.
idle:
welcome: |-
Спасибо, всё готово! Я буду на связи каждый день: напомню о препарате и спрошу о самочувствии. Если захотите что-то рассказать — просто напишите или выберите кнопку ниже.
menu_prompt: "Чем могу помочь?"
buttons:
checkin: "Как я сегодня"
med_question: "Вопрос о лекарстве"
contact_doctor: "Связаться с врачом"
revoke: "Отозвать согласие"
# Заглушки кнопок меню; ветки ветерана (#36/#37) и med_qa (#15) подменяют их своими триггерами.
stub_checkin: "Короткий опрос о самочувствии скоро появится здесь. А пока можно просто написать, как вы себя чувствуете сегодня."
stub_med_question: "Напишите ваш вопрос о препарате. Я отвечаю только по официальной инструкции, а если ответа в ней нет — передам вопрос врачу."
contact_doctor_ack: "Я отметил вашу просьбу связаться с врачом — она появится в вашей карточке у врача. Если ситуация срочная, звоните 112."
visit_stub: "Онлайн-запись появится здесь позже. Пока вы можете записаться к своему врачу через регистратуру клиники."
revoked:
confirm: |-
Согласие отозвано. Я больше не буду писать вам и обрабатывать ваши сообщения, а ваши данные помечены к удалению.
Если передумаете — отправьте /start. Если вам угрожает опасность — звоните 112.
crisis:
# Повторный острый сигнал в уже открытом кризисе: скрипт второй раз не шлём, только это + ресурсы.
repeat: "Я рядом. Ваш при угрозе жизни звоните 112. Если вам угрожает опасность прямо сейчас — позвоните:"
# Любое другое сообщение, пока кризис открыт (до подтверждения врача).
hold: "Я на связи и читаю вас. Не ждите ответа врача, если помощь нужна сейчас. Если станет хуже — звоните 112 или на телефон доверия +7 (495) 989-50-50."
# Врач подтвердил кризисный алерт (doctor_ack): выходим из кризиса.
closed: "Ваш врач получил сообщение и в курсе вашей ситуации. Спасибо, что остаётесь на связи. Если снова станет тяжело — напишите мне в любой момент."
scale:
intro: "Несколько коротких вопросов ({count}). {instruction}"
question: "{n}/{total}. {text}"
clarify: "Не совсем понял ответ. Выберите, пожалуйста, вариант кнопкой или опишите, как часто это было."
buttons_only: "На этот вопрос, пожалуйста, ответьте кнопкой."
done: "Спасибо, ответы сохранены. Ваш врач увидит результаты."
risk_screen_intro: "Спасибо, что поделились. Мне важно задать несколько коротких вопросов, чтобы лучше понять, как вы сейчас."
safety_plan:
title: "Ваш план безопасности:"
# Шаблон Стэнли–Брауна, если план ещё не составлен с врачом на визите.
template:
- "Мои тревожные сигналы: что я замечаю, когда становится хуже."
- "Что помогает мне самому: прогулка, душ, дыхание, любимое дело."
- "Люди и места, которые отвлекают: к кому можно пойти."
- "Кому позвонить, если тяжело: близкий человек, сослуживец."
- "Специалисты: ваш врач, телефон доверия +7 (495) 989-50-50, экстренные службы 112."
- "Безопасная среда: оружие и лишние лекарства — на хранение близкому человеку."
means_restriction: "Если дома есть оружие или запас лекарств — попросите близкого человека временно хранить их у себя."
reach_out:
silence: "Давно не было вестей от вас. Как вы? Можно ответить одним словом — мне важно знать, что с вами всё в порядке."
missed_doses: "Заметил, что несколько приёмов препарата пропущено. Всё ли в порядке? Если что-то мешает — расскажите, я передам врачу."
default: "Хотел узнать, как вы. Напишите пару слов, когда будет удобно."
visit:
button: "Записаться к врачу раньше"
unavailable: |-
Сейчас у меня технические неполадки, и я не могу сохранить ваше сообщение. Пожалуйста, напишите чуть позже.
Если вам плохо прямо сейчас — звоните 112 или на телефон доверия +7 (495) 989-50-50.
+33
View File
@@ -0,0 +1,33 @@
unavailable: "Этот опрос пока недоступен. Если нужна помощь, свяжитесь с врачом."
done: "Ответы сохранены. Дальнейшие действия можно обсудить с врачом."
review: "Опрос приостановлен. Для продолжения нужна оценка специалиста. Если есть непосредственная угроза жизни — позвоните 112."
paused: "Опрос приостановлен, ответы сохранены."
invalid: "Выберите один из предложенных вариантов ответа."
numeric: "Введите число часов от 0 до 24, например 6,5."
help: "Мне нужна помощь сейчас"
pause: "Продолжить позже"
resume: "Продолжить опрос"
start: "Пройти первичный опрос"
comment_skip: "Не добавлять комментарий"
recent_trauma: "Событие произошло менее месяца назад?"
sex: "Укажите пол для интерпретации алкогольной шкалы."
tbi: "Были ли у Вас контузия или черепно-мозговая травма?"
psychotropics: "Принимаете ли Вы сейчас психотропные препараты?"
missing_followup: "Для оценки изменений со времени прошлого контакта нужна консультация специалиста. Если Вам нужна помощь сейчас, нажмите кнопку ниже."
periods:
past_month: "За последний месяц."
lifetime: "За всю жизнь."
past_3_months: "За последние три месяца."
standard_drinks: |-
Стандартная порция (СП) = 10 г этанола. Таблица бланка RUS-AUDIT:
Вино/шампанское · пиво · креплёное вино · крепкий алкоголь
1–2 СП: до 250 мл · до 650 мл · до 170 мл · до 80 мл
3–4 СП: 251–450 мл · 651–1200 мл · 171–300 мл · 81–140 мл
5–6 СП: 451–660 мл · 1201–1750 мл · 301–430 мл · 141–210 мл
7–9 СП: 661–970 мл · 1751–2500 мл · 431–640 мл · 211–300 мл
10+ СП: более 970 мл · более 2500 мл · более 640 мл · более 300 мл
Если напитков несколько, учитывайте суммарное число стандартных порций.
as_of: "Период отсчитывается от даты начала этого опроса: {day}."
alcohol_advice: "Обсудите с врачом, как сократить употребление алкоголя и составить безопасный план."
+117
View File
@@ -0,0 +1,117 @@
# Реализация обновлённого опросника — PR #53
Спецификация: 19-страничный PDF, SHA-256
`b5e1cfd2b292526a8cec3bbb679294a90a446c4104a3615c0d8fcbf10e12fc0e`.
Состояние: техническая реализация для проверки; клиническое включение закрыто.
## Состав
- Первичный диалог: официальный русский C-SSRS Recent с условными переходами,
PHQ-2 → PHQ-9 при сумме ≥2, фильтр травмы → TSQ → PCL-5 при TSQ ≥6,
RUS-AUDIT-S 3/9/10 → полная форма по отдельно утверждаемому условию.
Вопросы о недавности травмы, ЧМТ и психотропных препаратах сохраняются отдельно.
- TSQ: дословная инструкция и десять пунктов приложения Г1, с.129–130 PDF
клинических рекомендаций 2023 года. Копия, источник и хеш —
`tsq-guideline-2023.pdf`, `tsq-source.json`. Актуальность редакции не подтверждена.
- Полная алкогольная форма сохраняет ответы 3/9/10 без повторного суммирования;
п.1=0 выводит только п.2=0. Конфликт с положительным п.3 передаётся на проверку.
Отказ указать пол не подменяется выбором мужских или женских границ.
- PHQ/PCL взяты из новой версии PDF; обратный перевод и таблица согласования
находятся в `revision-2026-10-03-v2/`. Тексты не считаются согласованными.
- Дневник: настроение, часы сна, восстановление после сна, приём лекарств и причина
пропуска, переносимость, вещества, необязательный комментарий; модули ПТСР,
депрессии, суицидальных мыслей. У группы с риском вопрос о мыслях задаётся первым.
Общая сумма дневника не вычисляется.
- Повторы: 7/14/28 дней от начала/смены терапии, затем 28; после кризиса PHQ-9 — 7,
PCL-5 — 14 с недельным периодом; специалист — 14/30; самопомощь — 28/28;
RUS-AUDIT-S — 30. При пересечении выбирается ближайшая дата. ЧМТ и алкогольные
направления сохраняются параллельно основному.
- Повторные PHQ/PCL сохраняют маршрут и изменение относительно последнего полного
результата той же версии и периода. Рост ≥5/≥10 помечается для повторного триажа.
Недельный PCL-5 не сравнивается с месячным. Переносимость уточняется при повторе
на фоне зафиксированной терапии.
## Остановка и работа врача
Положительный PHQ-9/9, высокий сигнал C-SSRS, мысли о смерти в дневнике и кнопка
помощи немедленно останавливают обычные вопросы. Сохраняются частичные ответы,
причина, дополнительные флаги и обращение. Кризисный ответ содержит 112 и кнопку
плана безопасности. Средний риск C-SSRS требует оценки специалиста; низкий при
утверждённой политике `graded` показывает план безопасности и обращение врачу.
Альтернатива `stop_any` прекращает опрос при первом «Да». Выбор остаётся за врачом.
**Русский Since Last Contact не получен.** При повторном мониторинге создаётся
отдельное задание для оценки специалистом; Recent не переименовывается и не
выдаётся за повторную форму. Перед дневником/плановым мониторингом предусмотрено
одно такое задание на местный календарный день. Обычный сценарий ждёт решения
врача. Это явно обозначенный временный порядок, который также требует согласования.
Дневниковое «Да» не ждёт окончания дневника или этой плановой процедуры.
Новые служебные маршруты:
- `GET /api/v1/patients/{token}/questionnaires?doctor_id=...` — ответы, версия,
периоды и результаты; чтение записывается в аудит.
- `POST /api/v1/patients/{token}/questionnaires/{record_id}/review` — поля
`doctor_id`, `assessment_performed: true`, `decision: resume | keep_hold`,
`reference` (идентификатор решения, без ФИО/свободного медицинского текста).
Проверяются принадлежность пациента врачу, согласие и статус записи.
Решение сохраняется с датой и аудитом. При открытом кризисе продолжение запрещено.
Простое подтверждение получения уведомления не снимает этот запрет проверки.
Как и остальной API этой ветки, эти маршруты предназначены для синтетического
прототипа: `doctor_id` передаёт вызывающая сторона. Перед реальными данными нужна
аутентификация врача; существующая карточка дашборда остаётся отдельной заглушкой.
## Надёжность и данные
Три новые таблицы: `questionnaire_records`, `questionnaire_notices`,
`questionnaire_tasks`. Они создаются существующим `init_db`; прежние результаты
шкал не переименовываются и не мигрируют. Ответ, переход и обращение фиксируются
в одной транзакции. Неполные суммы — `null`, C-SSRS не получает суммарного балла.
Выведенный п.2 RUS-AUDIT отмечается в `derived`, отсутствуя среди исходных ответов.
Период привязан к началу конкретного заполнения; у каждого ответа есть время.
Повторное нажатие и кнопка от другого вопроса не меняют следующий ответ.
Приостановленный опрос восстанавливается по сохранённой записи; смена версии
переводит его на проверку. После кризиса автоматического продолжения нет.
Отзыв согласия отменяет незавершённые записи, задания и неотправленные обращения.
Обращения врачу повторяются после ошибки с ограниченной задержкой; в ошибках
сохраняется только класс исключения. `accepted_by_adapter` означает приём адаптером,
`test_sink` — сохранение демонстрационным адаптером. Доставка человеку не утверждается.
Исходящие плановые сообщения сохраняются в задании до попытки отправки. Протокол
канала не поддерживает ключ идемпотентности: после падения между отправкой и
фиксацией возможна повторная доставка; потеря очереди внутри MemoryOutbox после
перезапуска также не является подтверждённой доставкой. Для реального запуска
нужен постоянный адаптер с подтверждением доставки.
Планировщик использует часы приложения и Europe/Moscow. PostgreSQL блокирует
параллельные запуски планировщика advisory lock, ответы пациента — блокировкой
строки. В SQLite демонстрации используется один процесс. Пропущенные тики не
создают пачку исторических анкет. Перезапуск не создаёт новое открытое задание
того же типа. Изменение схемы лекарств должно иметь новую дату начала в источнике
назначений; изменение дозировки без даты не угадывается.
## Предложенные определения для согласования
В `config/intake_runtime.yaml`: семь полных календарных дней ДО трёх дней ухудшения
образуют базовый уровень; пропуски не заменяются нулями. Для каждой из трёх ночей
проверяется «сон <4 часов ИЛИ кошмары ≥3». Только явное «не принимал» три дня подряд
считается пропуском; частичный приём и побочные эффекты — отдельные флаги.
Два дня без завершённого дневника в группе риска создают обращение для контакта;
сам звонок этим кодом не выполняется. При двух модулях подробные вопросы ПТСР и депрессии чередуются по календарным
дням; вопросы о риске остаются ежедневно. Обещание заполнить за одну минуту не используется. Якоря положительных показателей
и разбиение вопроса о сне — кандидатные формулировки для врача.
## Включение и остающиеся внешние действия
По умолчанию `enabled: false`; все подписи пусты. Для включения нужны решение по
C-SSRS, порог расширения алкоголя (сумма ИЛИ зона), подпись политики и три подписи
той же версии runtime: маршрутизация/дневники/расписания, содержание, контакты.
Тестовые подписи создаются только во временных каталогах тестов. После изменения
конфигурации требуется перезапуск приложения.
До работы с пациентами остаются: врачебное согласование, независимая проверка
перевода, проверка актуальной редакции КР и условий использования TSQ, получение
русского Since Last Contact, операционная проверка линий и готовности дежурного.
Публикация PR не выполняет ни один из этих пунктов.
Binary file not shown.
+203
View File
@@ -0,0 +1,203 @@
# Опросник: пакет проверки от 3 октября 2026
Статус: **подготовлено для проверки, не утверждено для пациентов**. Задача #52;
клиническое согласование остаётся в #8. Независимая проверка обратного перевода, согласование
врачом и проверка линий звонками не выполнены. В `review-status.json` все эти
статусы оставлены незавершёнными.
## Визуальный отчёт по PR #53
[Скачать PDF: проделанная работа и архитектура](PR53-implementation-report.pdf).
Восемь страниц: состав изменений, архитектура, опросы, расписания, хранение данных,
проверки и открытые вопросы. Срез коммита `c42ef62`; в отчёте отдельно отмечено
расхождение расписания для маршрута «психолог» (14/30 вместо 28/28 дней).
## Обновление: PDF на 19 страницах
Получена дополненная версия от 3 октября. [Разбор изменений](revision-2026-10-03-v2/REVIEW.md)
и [рабочий обратный перевод](revision-2026-10-03-v2/backtranslation-draft.md) сохранены
отдельно. TSQ заменил симптомные вопросы PC-PTSD-5; добавлены дневник и расписания.
**Новая спецификация подключена к диалогу за закрытым переключателем.**
[Состав реализации, ограничения и порядок проверки](IMPLEMENTATION.md).
Текст TSQ получен отдельно из приложения Г1 клинических рекомендаций 2023 года;
актуальность этой редакции ещё нужно подтвердить.
Независимая проверка перевода остаётся pending.
Описания реализованных правил и ссылки на страницы ниже относятся к исходному
14-страничному PDF, если явно не указана новая версия.
## Что реализовано
`app/core/questionnaire.py` содержит детерминированные правила первичного опроса:
короткий PHQ-2 из первых двух ответов PHQ-9, фильтр травмы PC-PTSD-5,
условное дополнение до полных шкал, RUS-AUDIT-S, структуру C-SSRS Recent,
приоритет основного маршрута и параллельные направления. Правила подключены к версионированному диалогу, дневнику и планировщику.
Замороженные контракты, `config/modules.yaml` и исторические результаты не менялись.
Клиническое включение заблокировано до подписей.
`config/questionnaire.yaml` фиксирует **кандидатные** пороги из PDF. Политика без
записи врача не выдаёт обычный маршрут. Положительный PHQ-9/9, известный высокий
сигнал C-SSRS и ручной запрос помощи дают `crisis` независимо от этого ограничения.
`clinical_review_required` при других положительных C-SSRS означает остановку
обычного потока и необходимость человеческой проверки, а не присвоение высокого
риска. Для неполного C-SSRS возвращается нижняя граница риска, итоговый риск пуст.
Использовать в адаптере `score_alcohol`, а не низкоуровневый `scales.score` для
маршрутизации: первый не выдаёт итоговую сумму/зону неполной анкеты. Известный пол
нужен только для интерпретации алкогольной шкалы; неизвестный не заменяется мужским
или женским. Нет ответа, отказ и технически пропущенный вопрос не равны нулю.
## RUS-AUDIT-S: исправление спецификации
В PDF на с.7–9 первые три пункта ошибочно названы RUS-AUDIT-S. Валидированная
краткая форма состоит из **пунктов 3, 9, 10** полного RUS-AUDIT, в этом порядке.
Пункт 3 имеет значения 0/1/2/3/4, пункты 9 и 10 — только 0/2/4; сумма 0–12.
| Зона | Женщины | Мужчины |
|---|---|---|
| I | 0–1 | 0–3 |
| II | 2–4 | 4–6 |
| III | 5 | 7 |
| IV | 6–12 | 8–12 |
Источник состава: [Neufeld et al., Bulletin of WHO, 2021](https://pmc.ncbi.nlm.nih.gov/articles/PMC8243036/).
Зоны и текст для самозаполнения: [Бунова и соавт., 2022, рис.3](https://www.mediasphera.ru/issues/profilakticheskaya-meditsina/2022/8/1230549482022081021),
DOI 10.17116/profmed20222508121. Рисунок визуально проверен, включая пояснение
слова «травма» во втором вопросе. В интерфейсе это пояснение нельзя опускать.
При дополнении до полной шкалы остаются пункты 1,2,4,5,6,7,8. Ответы на 3,9,10
переиспользуются по исходным ID и не суммируются дважды. Записывать версию и факт
получения ответа в краткой форме: её пояснение травмы отличается от полного текста.
Правило PDF `п.1 = 0` в новой версии выводит ноль только для 2; исходный 3 сохраняется, как и 9/10;
выведенные ответы возвращаются отдельно в `derived`, исходный словарь не меняется.
Полная форма и её зоны взяты из пользовательского PDF с.7–9, транскрипция является
материалом для согласования, не независимо подтверждённым русским бланком.
Для пункта 2 адаптеру нужна исходная таблица стандартных порций (с.8), а не
произвольное округление смешанных напитков. Применение полной интервью-формы
как самоопроса требует отдельного решения врача.
RUS-AUDIT: © ВОЗ, 2021; [отчёт ВОЗ](https://www.who.int/europe/publications/i/item/9789289055765),
CC BY-NC-SA 3.0 IGO. Использование в этом пакете — некоммерческий прототип.
Наличие шкалы здесь не означает одобрения продукта ВОЗ или авторами.
## C-SSRS: русский бланк Recent
Официальная [страница переводов Columbia](https://cssrs.columbia.edu/the-columbia-scale-c-ssrs/translations/)
ссылается на [Dropbox Screeners](https://www.dropbox.com/scl/fo/pkg1gwxxn1266rke8oite/AC2UFfOKCtefU-o9EEXP-yk?dl=0&rlkey=4grouuqbchfa8obxlcb3msnpz).
3 октября пользователь предоставил локальный `C-SSRS Screener - Russian.pdf`.
Неизменённая копия — `cssrs-screener-russian.pdf`, одна страница, заголовок
**Screen Version - Recent**. Вопросы, пояснения, периоды, варианты и цветовые
категории сверены с отрисованной страницей. Хеши оригинала и извлечённого текста
сохранены в `review-status.json`. Файл получен от пользователя после блокировки
загрузки; совпадение байтов с удалённой копией Dropbox независимо не проверено.
`config/instruments/cssrs_recent_ru.yaml` содержит отдельную транскрипцию,
`app/core/cssrs_content.py` — типизированный загрузчик `load_cssrs_russian`.
Версия `source-2026-10-03` — локальная версия содержания, **не номер редакции
Columbia**. Даты в метаданных PDF не используются как дата клинической редакции.
Сохранены все формулировки и опечатки, включая «Последние 1 месяца»,
«ЕСЛИ ДА 2», «пытался повеситься» и повтор «запаслись таблетками».
Нормализованы только межсловные пробелы и переносы. Пояснения интервьюеру
хранятся отдельно от вопросов. Любое редактирование требует отдельного решения.
Переходы по бланку: q1/q2 всем; при q2=0 перейти к q6; при q2=1 спросить q3–q5;
q6 за всю жизнь всем; при положительном q6 уточнить последние три месяца.
Для q1–q5 период — последний месяц. Ответы «Да»/«Нет» кодируются 1/0;
это коды ответов, **не суммируемые баллы**.
| Положительный ответ | Цвет бланка | Категория |
|---|---|---|
| q1, q2 | жёлтый | low |
| q3, q6_lifetime | оранжевый | moderate |
| q4, q5, q6_recent | красный | high |
При нескольких положительных ответах используется максимальная категория.
Поведение ранее трёх месяцев не превращается в «риска нет».
Правила совпадают с [Recent Screener для первичной помощи](https://cssrs.columbia.edu/wp-content/uploads/C-SSRS-Screener-with-Triage-Points-for-Primary-Care-2026.pdf)
и [описанием триажа Columbia](https://cssrs.columbia.edu/the-columbia-scale-c-ssrs/risk-identification/).
Неполные или противоречивые данные не получают итоговую категорию;
известный сигнал сохраняется как нижняя граница риска.
`config/scales/cssrs_screen.yaml` остаётся историческим демо-переводом. Его q6
спрашивает только о последних трёх месяцах; автоматически конвертировать такой
ответ в пожизненный нельзя. Новый бланк расположен вне `config/scales`, поскольку
действующий последовательный диалог не поддерживает его условные переходы.
Он ещё не включён в пациентский сценарий. Загрузчик текста сам по себе не
разрешает продолжение опроса: адаптер должен учитывать `evaluate_intake`,
а порядок обязательных вопросов получать из `score_cssrs_recent`.
Бланк не разрешает противоречие об остановке при любом «Да»: C01 остаётся
решением врача. Также нужны C06 (возраст/популяция, формат применения, обучение
и повторные замеры) и согласование пациентского сценария. Версию «с последнего
контакта» нельзя получить простой заменой заголовка Recent. Статус лицензии и
применимость к коммерческому продукту подтверждаются отдельно.
## Обратный перевод и независимая проверка
Для новой версии подготовлено 32 единицы: 28 PCL-5 и 4 из сохранённого блока
травмы. Русский текст сверён с PDF с.3 и 5–7. Перевод подготовлен как рабочий
вариант и требует независимой проверки. Результат и последующая сверка
с оригиналом разделены в `revision-2026-10-03-v2/`.
Независимому человеку передать **только**
`revision-2026-10-03-v2/backtranslation-blind.tsv`:
1. Переводчик со свободным английским не должен быть автором исходного русского
текста. Перевести все 32 единицы, не читая английский оригинал и подготовленный рабочий перевод.
2. Сохранить ID и порядок. Заполнить перевод, сомнения, имя и дату; русский текст
не исправлять. Если оригинал уже знаком, явно указать это ограничение.
3. Координатор и врач затем заполняют согласованный русский текст, решение,
подпись и дату в `revision-2026-10-03-v2/translation-reconciliation.tsv`.
Предварительные замечания в нём являются предложениями для проверки.
Исходные файлы `backtranslation-blind.tsv` и `translation-reconciliation.tsv`
в этой папке сохраняются как пакет прежней версии на 38 единиц. Пять симптомных
пунктов PC-PTSD-5 и его инструкция больше не входят в актуальный пакет.
Обратный перевод сам по себе не доказывает
психометрическую валидность. Клинические данные переводчикам не передаются.
## Решения врача — поля намеренно не заполнены
| ID | Решение, которое нужно зафиксировать | Статус |
|---|---|---|
| C01 | Любой «Да» останавливает обычный опрос или разрешено продолжение после низкого риска? Различить обычный опрос, уточнение риска и кризисную помощь. Новый PDF с.1 против с.12; в старом с.1 против с.11. Скриншот поддерживает остановку. | pending |
| C02 | Использовать сумму ≥1 или согласованную границу зоны для расширения RUS-AUDIT-S? Не переносить порог AUDIT-C ≥3/≥4. | pending |
| C03 | Самозаполнение полного RUS-AUDIT, таблица стандартных порций, порядок дополнения и повторные замеры. | pending |
| C04 | Новая спецификация: TSQ ≥6 вместо PC-PTSD-5 ≥3; получить приложение Г1, согласовать применение и PCL-5 ≥31 для популяции. TSQ из приложения Г1 редакции 2023 года уже добавлен; актуальность редакции и применение требуют подтверждения. | pending |
| C05 | Привязка PHQ-9/PCL-5 к специалистам, сроки срочной помощи, параллельные маршруты и состав флагов. | pending |
| C06 | Официальный русский C-SSRS: версия, возраст/популяция, применение Recent и Since Last Contact. | pending |
| C07 | Как отвечает живой дежурный, что делать при недоступности, когда подтверждать уведомление врачу. | pending |
| C08 | Повторы, ухудшение, разные периоды оценки и выход из кризисного режима; расписание после смены лекарств 7/14/28 дней и далее 4 недели. | pending |
| C09 | Дневник: якоря ответов, направление показателей, пропуски, окно среднего, ночные флаги, приверженность и сроки реакции. См. разбор новой версии. | pending |
Форма решения: ID; выбранный вариант; обоснование; ФИО/роль врача; дата с часовым
поясом; версия политики; ссылка на подписанный протокол; проверенные сценарии.
После решений C01/C02 и утверждения остальных применяемых порогов заполнить
`approval` в конфиге и изменить версию. Это запись о реальном согласовании, а не
переключатель для снятия блокировки. Синтетические записи в тестах не являются
подписью врача.
## Подключение к диалогу
Адаптер проверяет непосредственные сигналы после каждого ответа и на кнопке помощи;
при завершении вызывает `evaluate_intake` для общего маршрута.
`crisis` открывает фиксированную кризисную ветку до LLM; `clinical_review_required`
не разрешает автоматическое продолжение; `incomplete` запрашивает обязательные
пропуски; `ready` содержит основной маршрут и отдельные параллельные направления.
Кнопка помощи доступна на каждом экране. Не утверждать, что врач уведомлён,
пока интеграция не подтвердила доставку. Обычный опрос после кризиса сам не возобновляется.
Хранить ответы, версию шкалы/политики, период, время из `app.core.clock`, исходные
и выведенные значения раздельно, флаги и состояние полноты. Новые ключи C-SSRS
не подменяют старый cssrs_6. Нельзя сравнивать динамику разных версий и периодов
как одну и ту же шкалу. Клинические данные не отправляются переводчикам.
## Проверка
`make lint && make test`. Новые тесты проверяют все 45 допустимых наборов
RUS-AUDIT-S для каждого пола, границы полной формы, отрицательную и положительную
ветви C-SSRS, давнее поведение, пропуски/противоречия, кризис на неполном опросе,
конфликт PHQ-9/9 и C-SSRS, пороги маршрутов, независимые направления и отсутствие
разрешения на автоматический переход без реального решения врача.
Тесты C-SSRS дополнительно восстанавливают полный текст страницы из структуры,
проверяют хеши и сверяют все комбинации ветвей с цветовыми категориями бланка.
+39
View File
@@ -0,0 +1,39 @@
"scale" "item_id" "source_page" "russian_text" "english_backtranslation" "translator_comment" "translator_name" "completed_at"
"pcptsd5" "introduction" "3" "Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства." "" "" "" ""
"pcptsd5" "trauma_filter" "3" "Случалось ли с Вами подобное событие?" "" "" "" ""
"pcptsd5" "instruction" "3" "За последний месяц Вы…" "" "" "" ""
"pcptsd5" "pcptsd5_1" "4" "…видели кошмары об этом событии (событиях) или думали о нём, когда не хотели этого?" "" "" "" ""
"pcptsd5" "pcptsd5_2" "4" "…изо всех сил старались не думать о событии (событиях) или специально избегали ситуаций, которые о нём напоминали?" "" "" "" ""
"pcptsd5" "pcptsd5_3" "4" "…были постоянно настороже, начеку или легко вздрагивали?" "" "" "" ""
"pcptsd5" "pcptsd5_4" "4" "…чувствовали оцепенение или отстранённость от людей, занятий или того, что Вас окружает?" "" "" "" ""
"pcptsd5" "pcptsd5_5" "4" "…чувствовали вину или не могли перестать обвинять себя или других в событии (событиях) или в проблемах, которые оно могло вызвать?" "" "" "" ""
"pcptsd5" "option_Да" "3–4" "Да" "" "" "" ""
"pcptsd5" "option_Нет" "3–4" "Нет" "" "" "" ""
"pcl5" "instruction" "5" "Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц." "" "" "" ""
"pcl5" "optional_event" "5" "Ваше самое тяжёлое событие: ______" "" "" "" ""
"pcl5" "stem" "5" "Насколько сильно за последний месяц Вас беспокоили:" "" "" "" ""
"pcl5" "pcl5_1" "5" "Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?" "" "" "" ""
"pcl5" "pcl5_2" "5" "Повторяющиеся тяжёлые сны о стрессовом событии?" "" "" "" ""
"pcl5" "pcl5_3" "5" "Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?" "" "" "" ""
"pcl5" "pcl5_4" "5" "Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?" "" "" "" ""
"pcl5" "pcl5_5" "5" "Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?" "" "" "" ""
"pcl5" "pcl5_6" "5" "Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?" "" "" "" ""
"pcl5" "pcl5_7" "6" "Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?" "" "" "" ""
"pcl5" "pcl5_8" "6" "Трудности с тем, чтобы вспомнить важные части стрессового события?" "" "" "" ""
"pcl5" "pcl5_9" "6" "Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?" "" "" "" ""
"pcl5" "pcl5_10" "6" "Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?" "" "" "" ""
"pcl5" "pcl5_11" "6" "Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?" "" "" "" ""
"pcl5" "pcl5_12" "6" "Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?" "" "" "" ""
"pcl5" "pcl5_13" "6" "Ощущение отдалённости или оторванности от других людей?" "" "" "" ""
"pcl5" "pcl5_14" "6" "Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?" "" "" "" ""
"pcl5" "pcl5_15" "6" "Раздражительность, вспышки гнева или агрессивное поведение?" "" "" "" ""
"pcl5" "pcl5_16" "6" "Чрезмерный риск или поступки, которые могут причинить Вам вред?" "" "" "" ""
"pcl5" "pcl5_17" "6" "Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?" "" "" "" ""
"pcl5" "pcl5_18" "6" "Взвинченность или склонность легко вздрагивать?" "" "" "" ""
"pcl5" "pcl5_19" "6" "Трудности с концентрацией внимания?" "" "" "" ""
"pcl5" "pcl5_20" "7" "Трудности с засыпанием или прерывистый сон?" "" "" "" ""
"pcl5" "option_0" "5" "Совсем нет" "" "" "" ""
"pcl5" "option_1" "5" "Немного" "" "" "" ""
"pcl5" "option_2" "5" "Умеренно" "" "" "" ""
"pcl5" "option_3" "5" "Сильно" "" "" "" ""
"pcl5" "option_4" "5" "Крайне сильно" "" "" "" ""
1 scale item_id source_page russian_text english_backtranslation translator_comment translator_name completed_at
2 pcptsd5 introduction 3 Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства.
3 pcptsd5 trauma_filter 3 Случалось ли с Вами подобное событие?
4 pcptsd5 instruction 3 За последний месяц Вы…
5 pcptsd5 pcptsd5_1 4 …видели кошмары об этом событии (событиях) или думали о нём, когда не хотели этого?
6 pcptsd5 pcptsd5_2 4 …изо всех сил старались не думать о событии (событиях) или специально избегали ситуаций, которые о нём напоминали?
7 pcptsd5 pcptsd5_3 4 …были постоянно настороже, начеку или легко вздрагивали?
8 pcptsd5 pcptsd5_4 4 …чувствовали оцепенение или отстранённость от людей, занятий или того, что Вас окружает?
9 pcptsd5 pcptsd5_5 4 …чувствовали вину или не могли перестать обвинять себя или других в событии (событиях) или в проблемах, которые оно могло вызвать?
10 pcptsd5 option_Да 3–4 Да
11 pcptsd5 option_Нет 3–4 Нет
12 pcl5 instruction 5 Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц.
13 pcl5 optional_event 5 Ваше самое тяжёлое событие: ______
14 pcl5 stem 5 Насколько сильно за последний месяц Вас беспокоили:
15 pcl5 pcl5_1 5 Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?
16 pcl5 pcl5_2 5 Повторяющиеся тяжёлые сны о стрессовом событии?
17 pcl5 pcl5_3 5 Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?
18 pcl5 pcl5_4 5 Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?
19 pcl5 pcl5_5 5 Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?
20 pcl5 pcl5_6 5 Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?
21 pcl5 pcl5_7 6 Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?
22 pcl5 pcl5_8 6 Трудности с тем, чтобы вспомнить важные части стрессового события?
23 pcl5 pcl5_9 6 Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?
24 pcl5 pcl5_10 6 Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?
25 pcl5 pcl5_11 6 Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?
26 pcl5 pcl5_12 6 Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?
27 pcl5 pcl5_13 6 Ощущение отдалённости или оторванности от других людей?
28 pcl5 pcl5_14 6 Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?
29 pcl5 pcl5_15 6 Раздражительность, вспышки гнева или агрессивное поведение?
30 pcl5 pcl5_16 6 Чрезмерный риск или поступки, которые могут причинить Вам вред?
31 pcl5 pcl5_17 6 Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?
32 pcl5 pcl5_18 6 Взвинченность или склонность легко вздрагивать?
33 pcl5 pcl5_19 6 Трудности с концентрацией внимания?
34 pcl5 pcl5_20 7 Трудности с засыпанием или прерывистый сон?
35 pcl5 option_0 5 Совсем нет
36 pcl5 option_1 5 Немного
37 pcl5 option_2 5 Умеренно
38 pcl5 option_3 5 Сильно
39 pcl5 option_4 5 Крайне сильно
+43
View File
@@ -0,0 +1,43 @@
# Проверка контактов: 3 октября 2026
Проверены официальные страницы, **не телефонное соединение**. Никаких звонков
или уведомлений службам/врачу не выполнялось.
| Служба | Номер | Область применения | Что подтверждено сайтом |
|---|---|---|---|
| Экстренные службы | 112 | Россия, непосредственная угроза жизни | Круглосуточно и бесплатно: [МЧС](https://76.mchs.gov.ru/deyatelnost/poleznaya-informaciya/rekomendacii-naseleniyu/sistema-112) |
| Психологическая помощь МЧС | +7 (495) 989-50-50 | Психологическая помощь; московский городской номер | Номер опубликован [самой службой](https://psi.mchs.gov.ru/). Бесплатность звонка и доступность из конкретного региона не подтверждались. |
| Московская служба психологической помощи | 051 с городского; 8 (495) 051 с мобильного | Москва, региональный ресурс | [Страница службы](https://msph.ru/uslugi/neotlozhnaya-psikhologicheskaya-pomoshch): круглосуточно; городской звонок бесплатный, мобильный тарифицируется оператором. Не преобразовывать необычный короткий номер в выдуманный E.164. |
| Детский телефон доверия | 8-800-2000-122 | Дети, подростки и родители по вопросам ребёнка | [Официальная страница](https://telefon-doveria.ru/about/): аудитория, анонимность и бесплатность. Не универсальная взрослая кризисная линия. Часы работы конкретного регионального подразделения нужно уточнить. |
В `config/texts/crisis.yaml` универсальная взрослая ветка исправлена на линию МЧС;
детский номер сохранён в возрастной ветке с указанием аудитории. Безусловное
обещание уведомления врача удалено. Формулировки и готовность линий требуют
согласования перед включением нового опросника.
## Дополнения из новой версии PDF
| Ресурс | Проверка источника | Ограничение |
|---|---|---|
| МГППУ, 8-800-600-31-14 | [Сообщение МГППУ](https://mgppu.ru/news/10402) публикует номер для детей и родителей | Не включён как универсальная линия для всех взрослых; часы и доступность проверить у службы. |
| Санкт-Петербург, 8 (812) 708-40-41 | [Справочник администрации](https://www.gov.spb.ru/gov/admgub/obrasheniya/faq/13551/) указывает телефон Института «Гармония» для детей и взрослых | Региональный ресурс; охват Ленинградской области отдельно не подтверждён. Повторное открытие страницы дало ошибку; перед включением перепроверить. |
| Фонд «Защитники Отечества», 117, доб.1 | [Страница государственного медучреждения](https://xn--7--9kcgbq1fn7g.xn--p1ai/информация-для-участников-специальн/) указывает номер фонда | Сайт самого фонда при проверке недоступен. Номер поддержки ветеранов и семей не объявляется круглосуточной экстренной психиатрической линией. В активный кризисный экран не добавлен. |
Звонки по этим трём позициям также не выполнены.
Номер 112 проверять тестовым вызовом нельзя. Его назначение — реальная экстренная
ситуация. Технический тест `tel:112` проводить без совершения вызова. Для прочих
линий согласовать с оператором службы проверку доступности без имитации кризиса.
## Журнал операционной проверки (не выполнена)
| Служба | Проверяющий | Дата/часовой пояс | Регион/оператор связи | Доступность/часы/стоимость | Альтернативный ресурс | Результат |
|---|---|---|---|---|---|---|
| МЧС | | | | | | pending |
| МСППН | | | | | | pending |
| Детский телефон доверия | | | | | | pending |
Отдельно подтвердить наличие живого дежурного в самом продукте и время реакции.
Кнопка, мок или очередь уведомлений не являются подтверждённым контактом с человеком.
За пределами России приложение должно уточнять страну и давать местные экстренные
ресурсы, а не обещать доступность российских коротких номеров.
Binary file not shown.
@@ -0,0 +1 @@
КОЛУМБИЙСКАЯ ШКАЛА ОЦЕНКИ ТЯЖЕСТИ СУИЦИДА Screen Version - Recent Последние 1 месяца Задайте вопросы 1 и 2 Да Нет 1. Возникало ли у Вас желание умереть или заснуть и не просыпаться? 2. Думали ли Вы в самом деле о том, чтобы убить себя? ЕСЛИ ДА 2, Задайте вопросы 3, 4, 5 и 6. ЕСЛИ Нет, Задайте вопросы 6. 3. Думали ли Вы о том, как Вы могли бы это сделать? Пациент подтверждает, что его посещают мысли о самоубийстве и что в течение периода наблюдения он/она думал(-а) хотя бы об одном способе самоубийства. Данная ситуация отлична от такой, когда пациент разработал конкретный план: время, место или детали метода самоубийства (например, пациент думал о способе самоубийства, но не о конкретном плане). Сюда относятся люди, которые скажут: “Я думал(-а) о передозировке таблеток, но так и не построил(-а) конкретного плана о том, когда, где и как осуществить это…..и я бы никогда не довёл(-а) дело до конца”. 4. Посещали ли Вас подобные мысли, и было ли у Вас какое-нибудь намерение действовать в соответствии с ними? Активные мысли о самоубийстве при заявлении пациента о некотором намерении действовать в соответствии с этими мыслями, в отличие от подобного утверждения: “Такие мысли посещают меня, но я определенно не стану ничего предпринимать”. 5. Начали ли Вы разрабатывать или уже разработали детальный план самоубийства? Намереваетесь ли Вы осуществить этот план? 6. Вы когда-нибудь что-нибудь делали, начинали что-то делать или готовились сделать что-нибудь, чтобы покончить с собой? Примеры: запаслись таблетками, приобрели пистолет, раздали ценные вещи, написали завещание или предсмертную записку, запаслись таблетками, но не проглотили их, держали пистолет, но передумали стрелять или его кто-то выхватил из вашей руки, поднялись на крышу, но не прыгнули; или принимали таблетки, пытались застрелиться, порезались, пытался повеситься и т. д. ЕСЛИ ДА, то было ли это в последние 3 месяца? Low Risk Moderate Risk High Risk
Binary file not shown.
+53
View File
@@ -0,0 +1,53 @@
{
"prepared_on": "2026-10-03",
"source_pdf_sha256": "cad6008e08b256bc39732432832e12692bb4d296e34a501ed5e2d3da02ba05de",
"source_pdf": "questionnaires-mvp-source.pdf",
"translation_status": "draft_completed_independent_review_pending",
"clinician_approval": null,
"phone_checks": "not_performed",
"cssrs_russian_pdf": {
"original_filename": "C-SSRS Screener - Russian.pdf",
"received": true,
"acquisition": "user_provided_local_file",
"received_on": "2026-10-03",
"file": "cssrs-screener-russian.pdf",
"sha256": "d1dc77068b73e83a0e7959681b0fe9f15775a8b99863cf0e11794308a4c77c85",
"source_text": "cssrs-screener-russian.source.txt",
"source_text_sha256": "89835360bd59c6ead1a56bec6f40c46b72f81165366c92f85ba5d25291748de7",
"text_verified": true,
"visual_verification": true,
"pages": 1,
"form_subtitle": "Screen Version - Recent",
"content_version": "source-2026-10-03",
"publisher_version": null,
"source": "https://cssrs.columbia.edu/the-columbia-scale-c-ssrs/translations/",
"remote_bytes_independently_verified": false,
"clinical_use_approved": false,
"note": "User supplied the file after the official-source download workflow was blocked. Local content and colors checked; publisher version is not inferred from PDF metadata."
},
"intake_release_ready": false,
"policy_version": "mvp-v2-review-2026-10-03",
"latest_specification": {
"source_pdf": "revision-2026-10-03-v2/questionnaires-mvp-v2.pdf",
"sha256": "b5e1cfd2b292526a8cec3bbb679294a90a446c4104a3615c0d8fcbf10e12fc0e",
"pages": 19,
"review": "revision-2026-10-03-v2/REVIEW.md",
"implemented": true,
"pending_changes": [],
"tsq_verbatim_source_verified": true,
"current_guideline_edition_verified": false,
"implementation_scope": "gated_intake_diaries_schedules_manual_followup_risk_review",
"implementation": "IMPLEMENTATION.md"
},
"backtranslation": {
"units": 32,
"result": "revision-2026-10-03-v2/backtranslation-draft.tsv",
"reconciliation": "revision-2026-10-03-v2/translation-reconciliation.tsv",
"clinician_approval": null,
"independent_review": "not_performed"
},
"cssrs_since_contact": {
"russian_source_received": false,
"fallback": "clinician_assessment_required_before_routine_monitoring"
}
}
@@ -0,0 +1,103 @@
# Новая версия PDF и обратный перевод
Проверено 3 октября 2026. Новая версия — 19 страниц вместо 14; исходный PDF
сохранён без изменений как `questionnaires-mvp-v2.pdf`. Хеши обоих источников —
в `source-manifest.json`. Все страницы новой версии прочитаны и просмотрены.
Это разбор спецификации и языкового материала, не клиническое согласование.
Технический этап выполнен после разбора: [реализация и ограничения](../IMPLEMENTATION.md).
Ниже сохранены замечания к источнику с обновлённым статусом реализации.
## Что изменилось и как это влияет на реализацию
| Раздел новой версии | Изменение | Состояние реализации / дальнейшее действие |
|---|---|---|
| с.1, 3–4, 13 | TSQ вместо пяти симптомных вопросов PC-PTSD-5: 10 ответов, последняя неделя, реакция не реже двух раз, расширение при ≥6 | Реализован TSQ из приложения Г1 редакции 2023 года: 10 пунктов, порог 6; прежние результаты не переименованы. |
| с.3 | Вступление и фильтр травмы PC-PTSD-5 сохранены; при «Нет» TSQ не задаётся | Записывать «не проводился по фильтру», не десять нулевых ответов. Вопрос о травме не является одиннадцатым пунктом TSQ. |
| с.4 | Темы TSQ прямо обозначены как ориентиры, а не готовые вопросы | Нельзя показывать таблицу как официальный опросник. Дословный текст получен из приложения Г1 редакции 2023 года; актуальность редакции ещё не подтверждена. |
| с.5–7 | Обосновано сохранение PCL-5 вместо IES-R; предложено сравнение на пилоте | 20 русских пунктов, инструкции и варианты PCL-5 не изменились; поменялось расположение на страницах. Пилот и валидация не выполнены. |
| с.8–10 | RUS-AUDIT-S исправлен на 3/9/10; повтор тоже краткой формой | Совпадает с уже реализованным составом. Временное расширение при любом ненулевом ответе не равно переходу с зоны II. Решение C02 остаётся открытым. |
| с.8 | При п.1=0 теперь явно пропускается только п.2 | Исправлено: выводится только п.2=0; п.3 сохраняется. Конфликт останавливает поток для проверки. |
| с.10 | В PDF по-прежнему не вписаны зоны краткой формы | В PR они уже есть по рис.3 Буновой: женщины 0–1 / 2–4 / 5 / ≥6; мужчины 0–3 / 4–6 / 7 / ≥8. Это не автоматическое утверждение порога расширения. |
| с.11–12 | Добавлена ссылка на русские бланки Columbia | Recent уже получен и сверён в PR. Для мониторинга требуется отдельный официальный Since Last Contact; текущий Recent его не заменяет. |
| с.13–14 | Контроль после начала/смены лекарств на 7-й, 14-й, 28-й день, затем каждые 4 недели; при пересечении расписаний — более частое | Расписания и объединение реализованы, вопрос о переносимости добавлен как кандидатный. Требуются согласование и корректные даты изменений назначений. |
| с.14–16 | Ежедневный дневник: ядро, модули ПТСР, депрессии и суицидальных мыслей; тренды и внеплановые опросы | Это новые авторские вопросы и правила, не валидированная сокращённая шкала. Добавлены за закрытым переключателем; определения и якоря ждут согласования. |
| с.16–17 | Добавлена таблица соответствия КР ПТСР ID 753 | Утверждения PDF об актуальной редакции, силе рекомендаций и применимости требуют проверки по первоисточнику; ссылка PDF ведёт на вторичный правовой сайт. |
| с.18–19 | Добавлены кризисные телефоны и аудитории | Это список кандидатов, а не результат проверки доступности. Проверка источников дополнена в ../crisis-contacts.md; проверка соединения не выполнена. |
## Противоречия и недостающие определения
1. **C01 остаётся:** с.1 требует прекращать опрос при любом «Да», с.12 разрешает
продолжать при низком риске. Нужно различить прекращение обычной анкеты,
продолжение оценки риска и немедленную помощь. Обновление PDF не является подписью врача.
2. **Число вопросов C-SSRS:** на с.11 написано «от 2 до 6», но по показанным
переходам q1, q2 и q6 обязательны: минимум три основных вопроса, максимум шесть,
плюс условное уточнение давности q6. В данных это до семи отдельных ответов.
3. **Дневник:** в таблице ПТСР представлены B, C и E, но нет пункта кластера D.
Его нельзя описывать как охватывающий все кластеры PCL-5 или заменяющий полную шкалу.
4. **Направление шкал дневника:** интерес, энергия и надежда сформулированы
положительно, подавленность — отрицательно. Нужны якоря 0/4 и 0/10 и явное
направление каждого показателя; общая сумма сейчас не определена.
5. **Среднее за семь дней:** неясно, включаются ли текущие три дня ухудшения,
сколько измерений достаточно, как учитывать пропуски и часовой пояс.
6. **Ночные флаги:** «кошмары ≥3 или сон <4 часов три ночи подряд» допускает два
прочтения длительности. Нужна однозначная группировка условий.
7. **Лекарства и пропуски:** нужны «не назначены», несколько препаратов,
частичный приём и назначение по необходимости. Порог «2–3 дня без чек-ина»
должен стать точным правилом с ответственным и сроком реакции.
8. **Нагрузка:** ответ о сне содержит две величины; ядро с модулем и условными
уточнениями может превышать обещанные 8–10 ответов и одну минуту. Суицидальные
вопросы не должны исчезать из-за чередования модулей.
9. **Мониторинг:** определить периоды PCL-5 на каждом расписании, точку сравнения
для повторного триажа и действия при пересечении окон. Рост суммы разных
версий нельзя автоматически считать сопоставимым изменением.
Эти замечания являются выводами разбора, а не утверждёнными новыми правилами.
Числовые значения дневника пока нельзя переносить в работающий сценарий.
## Обратный перевод выполнен
Переведены 32 единицы: PCL-5 — инструкция, поле события, общий вопрос, 20 пунктов
и 5 вариантов ответа; блок травмы — вступление, фильтр и 2 варианта ответа.
Русские формулировки сверены с новой версией; исключены устаревшие пять вопросов
PC-PTSD-5 и его отдельная инструкция о месяце.
- `backtranslation-draft.md` — удобный для чтения русский/английский текст и комментарии.
- `backtranslation-draft.tsv` — зафиксированный результат, с датой подготовки; сведения о проверяющем заполняются при согласовании.
- `translation-reconciliation.tsv` — последующая предварительная сверка PCL-5 с оригиналом,
поля согласованного русского текста и подписи врача пусты.
- `backtranslation-blind.tsv` — отдельный незаполненный пакет для независимого человека.
Английский оригинал не открывался во время перевода. После сохранения результата
проведена отдельная сверка с [официальным бланком VA, с.3](https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf).
На обложке указано 29 августа 2023, в нижнем колонтитуле бланка — 18 августа 2023;
обе даты записаны без попытки выбрать одну. Четыре единицы блока травмы в этом
проходе с английским первоисточником не сопоставлялись.
Главные замечания к PCL-5 после сверки:
| Пункт | Что требует внимания |
|---|---|
| 1–2 | «Тяжёлые» допускает неоднозначное понимание эмоционального воздействия воспоминаний и снов. |
| 15 | «Раздражительность» может описывать состояние; английский пункт явно указывает раздражительное поведение. |
| 17 | «Всё время» добавляет требование постоянства. |
| 18 | «Взвинченность» может пониматься шире, чем повышенная пугливость. |
| 20 | Прерывистость сна и трудность сохранять сон близки, но не тождественны. |
| Ответ 3 | Русское «Сильно» требует проверки градации интенсивности относительно оригинала и соседних ответов. |
Русский текст не исправлялся. Перевод имеет статус рабочего варианта и требует
независимой проверки. Заключение о языковой эквивалентности, клиническое
согласование и психометрическая валидация не выполнены.
## Что остаётся после этого разбора
Техническая реализация новой версии подготовлена в PR #53. Полное описание —
[IMPLEMENTATION.md](../IMPLEMENTATION.md). Клиническое включение остаётся закрытым:
врачебные решения, независимая проверка перевода и операционная проверка линий
не выполнены; отдельная русская форма Since Last Contact не получена.
Попытка проверить актуальную карточку [КР 753_1](https://cr.minzdrav.gov.ru/preview-cr/753_1)
через веб-инструмент не дала доступного содержимого; актуальность редакции
на дату разбора не подтверждена. Утверждения PDF о рекомендациях не отмечены
как независимо проверенные. Наличие ссылки или таблицы не подтверждает
клиническое соответствие всего приложения.
@@ -0,0 +1,33 @@
"scale" "item_id" "source_page" "russian_text" "english_backtranslation" "translator_comment" "translator_name" "completed_at"
"trauma_filter" "introduction" "3" "Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства." "" "" "" ""
"trauma_filter" "trauma_filter" "3" "Случалось ли с Вами подобное событие?" "" "" "" ""
"trauma_filter" "option_Да" "3" "Да" "" "" "" ""
"trauma_filter" "option_Нет" "3" "Нет" "" "" "" ""
"pcl5" "instruction" "5" "Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц." "" "" "" ""
"pcl5" "optional_event" "5" "Ваше самое тяжёлое событие: ______" "" "" "" ""
"pcl5" "stem" "5" "Насколько сильно за последний месяц Вас беспокоили:" "" "" "" ""
"pcl5" "pcl5_1" "5" "Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?" "" "" "" ""
"pcl5" "pcl5_2" "6" "Повторяющиеся тяжёлые сны о стрессовом событии?" "" "" "" ""
"pcl5" "pcl5_3" "6" "Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?" "" "" "" ""
"pcl5" "pcl5_4" "6" "Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?" "" "" "" ""
"pcl5" "pcl5_5" "6" "Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?" "" "" "" ""
"pcl5" "pcl5_6" "6" "Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?" "" "" "" ""
"pcl5" "pcl5_7" "6" "Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?" "" "" "" ""
"pcl5" "pcl5_8" "6" "Трудности с тем, чтобы вспомнить важные части стрессового события?" "" "" "" ""
"pcl5" "pcl5_9" "6" "Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?" "" "" "" ""
"pcl5" "pcl5_10" "6" "Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?" "" "" "" ""
"pcl5" "pcl5_11" "6" "Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?" "" "" "" ""
"pcl5" "pcl5_12" "6" "Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?" "" "" "" ""
"pcl5" "pcl5_13" "6" "Ощущение отдалённости или оторванности от других людей?" "" "" "" ""
"pcl5" "pcl5_14" "7" "Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?" "" "" "" ""
"pcl5" "pcl5_15" "7" "Раздражительность, вспышки гнева или агрессивное поведение?" "" "" "" ""
"pcl5" "pcl5_16" "7" "Чрезмерный риск или поступки, которые могут причинить Вам вред?" "" "" "" ""
"pcl5" "pcl5_17" "7" "Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?" "" "" "" ""
"pcl5" "pcl5_18" "7" "Взвинченность или склонность легко вздрагивать?" "" "" "" ""
"pcl5" "pcl5_19" "7" "Трудности с концентрацией внимания?" "" "" "" ""
"pcl5" "pcl5_20" "7" "Трудности с засыпанием или прерывистый сон?" "" "" "" ""
"pcl5" "option_0" "5" "Совсем нет" "" "" "" ""
"pcl5" "option_1" "5" "Немного" "" "" "" ""
"pcl5" "option_2" "5" "Умеренно" "" "" "" ""
"pcl5" "option_3" "5" "Сильно" "" "" "" ""
"pcl5" "option_4" "5" "Крайне сильно" "" "" "" ""
1 scale item_id source_page russian_text english_backtranslation translator_comment translator_name completed_at
2 trauma_filter introduction 3 Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства.
3 trauma_filter trauma_filter 3 Случалось ли с Вами подобное событие?
4 trauma_filter option_Да 3 Да
5 trauma_filter option_Нет 3 Нет
6 pcl5 instruction 5 Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц.
7 pcl5 optional_event 5 Ваше самое тяжёлое событие: ______
8 pcl5 stem 5 Насколько сильно за последний месяц Вас беспокоили:
9 pcl5 pcl5_1 5 Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?
10 pcl5 pcl5_2 6 Повторяющиеся тяжёлые сны о стрессовом событии?
11 pcl5 pcl5_3 6 Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?
12 pcl5 pcl5_4 6 Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?
13 pcl5 pcl5_5 6 Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?
14 pcl5 pcl5_6 6 Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?
15 pcl5 pcl5_7 6 Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?
16 pcl5 pcl5_8 6 Трудности с тем, чтобы вспомнить важные части стрессового события?
17 pcl5 pcl5_9 6 Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?
18 pcl5 pcl5_10 6 Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?
19 pcl5 pcl5_11 6 Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?
20 pcl5 pcl5_12 6 Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?
21 pcl5 pcl5_13 6 Ощущение отдалённости или оторванности от других людей?
22 pcl5 pcl5_14 7 Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?
23 pcl5 pcl5_15 7 Раздражительность, вспышки гнева или агрессивное поведение?
24 pcl5 pcl5_16 7 Чрезмерный риск или поступки, которые могут причинить Вам вред?
25 pcl5 pcl5_17 7 Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?
26 pcl5 pcl5_18 7 Взвинченность или склонность легко вздрагивать?
27 pcl5 pcl5_19 7 Трудности с концентрацией внимания?
28 pcl5 pcl5_20 7 Трудности с засыпанием или прерывистый сон?
29 pcl5 option_0 5 Совсем нет
30 pcl5 option_1 5 Немного
31 pcl5 option_2 5 Умеренно
32 pcl5 option_3 5 Сильно
33 pcl5 option_4 5 Крайне сильно
@@ -0,0 +1,221 @@
# Обратный перевод: русский → английский
Источник: обновлённый PDF, 19 страниц, с.3 и 5–7. **Рабочий вариант, требует независимой проверки и согласования.**
Русский текст сохранён. 32 единицы: 28 PCL-5 и 4 из блока о травме. TSQ не включён: PDF содержит темы, а не дословные вопросы. Авторский дневник не имеет указанного английского оригинала, поэтому его перевод не является обратным.
Исходный результат зафиксирован в `backtranslation-draft.tsv`; последующая сверка с оригиналом ведётся отдельно. Пустой `backtranslation-blind.tsv` оставлен для независимого переводчика.
## trauma_filter / introduction · с. 3
Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства.
**EN:** Sometimes people experience events that are exceptionally frightening, horrifying, or traumatic. For example: a serious accident or fire; physical or sexual assault or violence; an earthquake or flood; war; witnessing someone being killed or seriously wounded; a loved one dying as a result of murder or suicide.
## trauma_filter / trauma_filter · с. 3
Случалось ли с Вами подобное событие?
**EN:** Has an event like this happened to you?
**Комментарий переводчика:** «С Вами» may be read as direct personal exposure despite witnessing and bereavement examples; retain the introduction when reviewing.
## trauma_filter / option_Да · с. 3
Да
**EN:** Yes
## trauma_filter / option_Нет · с. 3
Нет
**EN:** No
## pcl5 / instruction · с. 5
Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц.
**EN:** Listed below are problems that people sometimes experience after a very difficult, stressful event. Keeping in mind the event that was most difficult for you, read each item carefully and choose the answer that shows how much this problem bothered you over the past month.
**Комментарий переводчика:** «Тяжёлый» is interpreted as emotionally difficult, not physical weight. «Самое тяжёлое» is comparative; «держать в уме» means keep in mind.
## pcl5 / optional_event · с. 5
Ваше самое тяжёлое событие: ______
**EN:** The event that was most difficult for you: ______
## pcl5 / stem · с. 5
Насколько сильно за последний месяц Вас беспокоили:
**EN:** Over the past month, how much were you bothered by:
## pcl5 / pcl5_1 · с. 5
Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?
**EN:** Recurring distressing and unwanted memories of the stressful event?
**Комментарий переводчика:** «Тяжёлые» is interpreted as distressing; other readings include severe or difficult. No additional symptom descriptor was inserted.
## pcl5 / pcl5_2 · с. 6
Повторяющиеся тяжёлые сны о стрессовом событии?
**EN:** Recurring distressing dreams about the stressful event?
**Комментарий переводчика:** «Тяжёлые сны» is interpreted as distressing dreams; not automatically rewritten as nightmares.
## pcl5 / pcl5_3 · с. 6
Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?
**EN:** Suddenly feeling or acting as though the stressful event were happening again (as though you were actually back there, experiencing it all over again)?
## pcl5 / pcl5_4 · с. 6
Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?
**EN:** Becoming very upset when something reminded you of the stressful event?
**Комментарий переводчика:** «Расстройство» is interpreted as emotional upset, not a diagnosed disorder.
## pcl5 / pcl5_5 · с. 6
Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?
**EN:** Strong bodily reactions when something reminded you of the stressful event (for example, a racing heart, difficulty breathing, sweating)?
## pcl5 / pcl5_6 · с. 6
Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?
**EN:** Avoiding memories, thoughts, or feelings associated with the stressful event?
## pcl5 / pcl5_7 · с. 6
Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?
**EN:** Avoiding external reminders of the stressful event (for example, people, places, conversations, activities, objects, or situations)?
## pcl5 / pcl5_8 · с. 6
Трудности с тем, чтобы вспомнить важные части стрессового события?
**EN:** Difficulty remembering important parts of the stressful event?
## pcl5 / pcl5_9 · с. 6
Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?
**EN:** Strong negative beliefs about yourself, other people, or the world (for example, thoughts such as “I am bad,” “there is something seriously wrong with me,” “no one can be trusted,” “the world is completely dangerous”)?
**Комментарий переводчика:** «Мир абсолютно опасен» retains its categorical intensity; not softened to generally unsafe.
## pcl5 / pcl5_10 · с. 6
Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?
**EN:** Blaming yourself or someone else for the stressful event or for what happened afterward?
## pcl5 / pcl5_11 · с. 6
Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?
**EN:** Strong negative feelings such as fear, horror, anger, guilt, or shame?
## pcl5 / pcl5_12 · с. 6
Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?
**EN:** Losing interest in activities that used to give you pleasure?
## pcl5 / pcl5_13 · с. 6
Ощущение отдалённости или оторванности от других людей?
**EN:** Feeling distant or cut off from other people?
## pcl5 / pcl5_14 · с. 7
Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?
**EN:** Difficulty experiencing positive feelings (for example, being unable to feel joy or love for people close to you)?
**Комментарий переводчика:** «Близкие люди» means people close to the respondent, not necessarily relatives.
## pcl5 / pcl5_15 · с. 7
Раздражительность, вспышки гнева или агрессивное поведение?
**EN:** Irritability, outbursts of anger, or aggressive behavior?
## pcl5 / pcl5_16 · с. 7
Чрезмерный риск или поступки, которые могут причинить Вам вред?
**EN:** Excessive risk-taking or actions that could harm you?
**Комментарий переводчика:** «Чрезмерный риск» rendered as excessive risk-taking; actual injury is not implied.
## pcl5 / pcl5_17 · с. 7
Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?
**EN:** Increased vigilance, wariness, or a feeling that you need to be on guard all the time?
## pcl5 / pcl5_18 · с. 7
Взвинченность или склонность легко вздрагивать?
**EN:** Feeling keyed up or being easily startled?
**Комментарий переводчика:** «Взвинченность» can mean being keyed up, tense, or agitated; the broader Russian reading should be reviewed.
## pcl5 / pcl5_19 · с. 7
Трудности с концентрацией внимания?
**EN:** Difficulty concentrating?
## pcl5 / pcl5_20 · с. 7
Трудности с засыпанием или прерывистый сон?
**EN:** Difficulty falling asleep or interrupted sleep?
**Комментарий переводчика:** «Прерывистый сон» means interrupted sleep; no additional sleep condition was inserted.
## pcl5 / option_0 · с. 5
Совсем нет
**EN:** Not at all
## pcl5 / option_1 · с. 5
Немного
**EN:** A little
## pcl5 / option_2 · с. 5
Умеренно
**EN:** Moderately
## pcl5 / option_3 · с. 5
Сильно
**EN:** Very much
**Комментарий переводчика:** Intensity anchor translated as Very much; requires review together with the question stem and other anchors.
## pcl5 / option_4 · с. 5
Крайне сильно
**EN:** Extremely
@@ -0,0 +1,33 @@
"scale" "item_id" "source_page" "russian_text" "english_backtranslation" "translator_comment" "translator_name" "completed_at"
"trauma_filter" "introduction" "3" "Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства." "Sometimes people experience events that are exceptionally frightening, horrifying, or traumatic. For example: a serious accident or fire; physical or sexual assault or violence; an earthquake or flood; war; witnessing someone being killed or seriously wounded; a loved one dying as a result of murder or suicide." "" "" "2026-10-03"
"trauma_filter" "trauma_filter" "3" "Случалось ли с Вами подобное событие?" "Has an event like this happened to you?" "«С Вами» may be read as direct personal exposure despite witnessing and bereavement examples; retain the introduction when reviewing." "" "2026-10-03"
"trauma_filter" "option_Да" "3" "Да" "Yes" "" "" "2026-10-03"
"trauma_filter" "option_Нет" "3" "Нет" "No" "" "" "2026-10-03"
"pcl5" "instruction" "5" "Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц." "Listed below are problems that people sometimes experience after a very difficult, stressful event. Keeping in mind the event that was most difficult for you, read each item carefully and choose the answer that shows how much this problem bothered you over the past month." "«Тяжёлый» is interpreted as emotionally difficult, not physical weight. «Самое тяжёлое» is comparative; «держать в уме» means keep in mind." "" "2026-10-03"
"pcl5" "optional_event" "5" "Ваше самое тяжёлое событие: ______" "The event that was most difficult for you: ______" "" "" "2026-10-03"
"pcl5" "stem" "5" "Насколько сильно за последний месяц Вас беспокоили:" "Over the past month, how much were you bothered by:" "" "" "2026-10-03"
"pcl5" "pcl5_1" "5" "Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?" "Recurring distressing and unwanted memories of the stressful event?" "«Тяжёлые» is interpreted as distressing; other readings include severe or difficult. No additional symptom descriptor was inserted." "" "2026-10-03"
"pcl5" "pcl5_2" "6" "Повторяющиеся тяжёлые сны о стрессовом событии?" "Recurring distressing dreams about the stressful event?" "«Тяжёлые сны» is interpreted as distressing dreams; not automatically rewritten as nightmares." "" "2026-10-03"
"pcl5" "pcl5_3" "6" "Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?" "Suddenly feeling or acting as though the stressful event were happening again (as though you were actually back there, experiencing it all over again)?" "" "" "2026-10-03"
"pcl5" "pcl5_4" "6" "Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?" "Becoming very upset when something reminded you of the stressful event?" "«Расстройство» is interpreted as emotional upset, not a diagnosed disorder." "" "2026-10-03"
"pcl5" "pcl5_5" "6" "Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?" "Strong bodily reactions when something reminded you of the stressful event (for example, a racing heart, difficulty breathing, sweating)?" "" "" "2026-10-03"
"pcl5" "pcl5_6" "6" "Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?" "Avoiding memories, thoughts, or feelings associated with the stressful event?" "" "" "2026-10-03"
"pcl5" "pcl5_7" "6" "Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?" "Avoiding external reminders of the stressful event (for example, people, places, conversations, activities, objects, or situations)?" "" "" "2026-10-03"
"pcl5" "pcl5_8" "6" "Трудности с тем, чтобы вспомнить важные части стрессового события?" "Difficulty remembering important parts of the stressful event?" "" "" "2026-10-03"
"pcl5" "pcl5_9" "6" "Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?" "Strong negative beliefs about yourself, other people, or the world (for example, thoughts such as “I am bad,” “there is something seriously wrong with me,” “no one can be trusted,” “the world is completely dangerous”)?" "«Мир абсолютно опасен» retains its categorical intensity; not softened to generally unsafe." "" "2026-10-03"
"pcl5" "pcl5_10" "6" "Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?" "Blaming yourself or someone else for the stressful event or for what happened afterward?" "" "" "2026-10-03"
"pcl5" "pcl5_11" "6" "Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?" "Strong negative feelings such as fear, horror, anger, guilt, or shame?" "" "" "2026-10-03"
"pcl5" "pcl5_12" "6" "Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?" "Losing interest in activities that used to give you pleasure?" "" "" "2026-10-03"
"pcl5" "pcl5_13" "6" "Ощущение отдалённости или оторванности от других людей?" "Feeling distant or cut off from other people?" "" "" "2026-10-03"
"pcl5" "pcl5_14" "7" "Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?" "Difficulty experiencing positive feelings (for example, being unable to feel joy or love for people close to you)?" "«Близкие люди» means people close to the respondent, not necessarily relatives." "" "2026-10-03"
"pcl5" "pcl5_15" "7" "Раздражительность, вспышки гнева или агрессивное поведение?" "Irritability, outbursts of anger, or aggressive behavior?" "" "" "2026-10-03"
"pcl5" "pcl5_16" "7" "Чрезмерный риск или поступки, которые могут причинить Вам вред?" "Excessive risk-taking or actions that could harm you?" "«Чрезмерный риск» rendered as excessive risk-taking; actual injury is not implied." "" "2026-10-03"
"pcl5" "pcl5_17" "7" "Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?" "Increased vigilance, wariness, or a feeling that you need to be on guard all the time?" "" "" "2026-10-03"
"pcl5" "pcl5_18" "7" "Взвинченность или склонность легко вздрагивать?" "Feeling keyed up or being easily startled?" "«Взвинченность» can mean being keyed up, tense, or agitated; the broader Russian reading should be reviewed." "" "2026-10-03"
"pcl5" "pcl5_19" "7" "Трудности с концентрацией внимания?" "Difficulty concentrating?" "" "" "2026-10-03"
"pcl5" "pcl5_20" "7" "Трудности с засыпанием или прерывистый сон?" "Difficulty falling asleep or interrupted sleep?" "«Прерывистый сон» means interrupted sleep; no additional sleep condition was inserted." "" "2026-10-03"
"pcl5" "option_0" "5" "Совсем нет" "Not at all" "" "" "2026-10-03"
"pcl5" "option_1" "5" "Немного" "A little" "" "" "2026-10-03"
"pcl5" "option_2" "5" "Умеренно" "Moderately" "" "" "2026-10-03"
"pcl5" "option_3" "5" "Сильно" "Very much" "Intensity anchor translated as Very much; requires review together with the question stem and other anchors." "" "2026-10-03"
"pcl5" "option_4" "5" "Крайне сильно" "Extremely" "" "" "2026-10-03"
1 scale item_id source_page russian_text english_backtranslation translator_comment translator_name completed_at
2 trauma_filter introduction 3 Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства. Sometimes people experience events that are exceptionally frightening, horrifying, or traumatic. For example: a serious accident or fire; physical or sexual assault or violence; an earthquake or flood; war; witnessing someone being killed or seriously wounded; a loved one dying as a result of murder or suicide. 2026-10-03
3 trauma_filter trauma_filter 3 Случалось ли с Вами подобное событие? Has an event like this happened to you? «С Вами» may be read as direct personal exposure despite witnessing and bereavement examples; retain the introduction when reviewing. 2026-10-03
4 trauma_filter option_Да 3 Да Yes 2026-10-03
5 trauma_filter option_Нет 3 Нет No 2026-10-03
6 pcl5 instruction 5 Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц. Listed below are problems that people sometimes experience after a very difficult, stressful event. Keeping in mind the event that was most difficult for you, read each item carefully and choose the answer that shows how much this problem bothered you over the past month. «Тяжёлый» is interpreted as emotionally difficult, not physical weight. «Самое тяжёлое» is comparative; «держать в уме» means keep in mind. 2026-10-03
7 pcl5 optional_event 5 Ваше самое тяжёлое событие: ______ The event that was most difficult for you: ______ 2026-10-03
8 pcl5 stem 5 Насколько сильно за последний месяц Вас беспокоили: Over the past month, how much were you bothered by: 2026-10-03
9 pcl5 pcl5_1 5 Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии? Recurring distressing and unwanted memories of the stressful event? «Тяжёлые» is interpreted as distressing; other readings include severe or difficult. No additional symptom descriptor was inserted. 2026-10-03
10 pcl5 pcl5_2 6 Повторяющиеся тяжёлые сны о стрессовом событии? Recurring distressing dreams about the stressful event? «Тяжёлые сны» is interpreted as distressing dreams; not automatically rewritten as nightmares. 2026-10-03
11 pcl5 pcl5_3 6 Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)? Suddenly feeling or acting as though the stressful event were happening again (as though you were actually back there, experiencing it all over again)? 2026-10-03
12 pcl5 pcl5_4 6 Сильное расстройство, когда что-то напоминало Вам о стрессовом событии? Becoming very upset when something reminded you of the stressful event? «Расстройство» is interpreted as emotional upset, not a diagnosed disorder. 2026-10-03
13 pcl5 pcl5_5 6 Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)? Strong bodily reactions when something reminded you of the stressful event (for example, a racing heart, difficulty breathing, sweating)? 2026-10-03
14 pcl5 pcl5_6 6 Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием? Avoiding memories, thoughts, or feelings associated with the stressful event? 2026-10-03
15 pcl5 pcl5_7 6 Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)? Avoiding external reminders of the stressful event (for example, people, places, conversations, activities, objects, or situations)? 2026-10-03
16 pcl5 pcl5_8 6 Трудности с тем, чтобы вспомнить важные части стрессового события? Difficulty remembering important parts of the stressful event? 2026-10-03
17 pcl5 pcl5_9 6 Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)? Strong negative beliefs about yourself, other people, or the world (for example, thoughts such as “I am bad,” “there is something seriously wrong with me,” “no one can be trusted,” “the world is completely dangerous”)? «Мир абсолютно опасен» retains its categorical intensity; not softened to generally unsafe. 2026-10-03
18 pcl5 pcl5_10 6 Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него? Blaming yourself or someone else for the stressful event or for what happened afterward? 2026-10-03
19 pcl5 pcl5_11 6 Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд? Strong negative feelings such as fear, horror, anger, guilt, or shame? 2026-10-03
20 pcl5 pcl5_12 6 Потеря интереса к занятиям, которые раньше приносили Вам удовольствие? Losing interest in activities that used to give you pleasure? 2026-10-03
21 pcl5 pcl5_13 6 Ощущение отдалённости или оторванности от других людей? Feeling distant or cut off from other people? 2026-10-03
22 pcl5 pcl5_14 7 Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)? Difficulty experiencing positive feelings (for example, being unable to feel joy or love for people close to you)? «Близкие люди» means people close to the respondent, not necessarily relatives. 2026-10-03
23 pcl5 pcl5_15 7 Раздражительность, вспышки гнева или агрессивное поведение? Irritability, outbursts of anger, or aggressive behavior? 2026-10-03
24 pcl5 pcl5_16 7 Чрезмерный риск или поступки, которые могут причинить Вам вред? Excessive risk-taking or actions that could harm you? «Чрезмерный риск» rendered as excessive risk-taking; actual injury is not implied. 2026-10-03
25 pcl5 pcl5_17 7 Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку? Increased vigilance, wariness, or a feeling that you need to be on guard all the time? 2026-10-03
26 pcl5 pcl5_18 7 Взвинченность или склонность легко вздрагивать? Feeling keyed up or being easily startled? «Взвинченность» can mean being keyed up, tense, or agitated; the broader Russian reading should be reviewed. 2026-10-03
27 pcl5 pcl5_19 7 Трудности с концентрацией внимания? Difficulty concentrating? 2026-10-03
28 pcl5 pcl5_20 7 Трудности с засыпанием или прерывистый сон? Difficulty falling asleep or interrupted sleep? «Прерывистый сон» means interrupted sleep; no additional sleep condition was inserted. 2026-10-03
29 pcl5 option_0 5 Совсем нет Not at all 2026-10-03
30 pcl5 option_1 5 Немного A little 2026-10-03
31 pcl5 option_2 5 Умеренно Moderately 2026-10-03
32 pcl5 option_3 5 Сильно Very much Intensity anchor translated as Very much; requires review together with the question stem and other anchors. 2026-10-03
33 pcl5 option_4 5 Крайне сильно Extremely 2026-10-03
@@ -0,0 +1,27 @@
{
"source": "questionnaires-mvp-v2.pdf",
"sha256": "b5e1cfd2b292526a8cec3bbb679294a90a446c4104a3615c0d8fcbf10e12fc0e",
"pages": 19,
"previous_source": "../questionnaires-mvp-source.pdf",
"previous_sha256": "cad6008e08b256bc39732432832e12692bb4d296e34a501ed5e2d3da02ba05de",
"units": 32,
"scope": "PCL-5 (28 units) and retained trauma introduction/filter/options (4 units)",
"excluded": "TSQ contains topic outlines only; five removed PC-PTSD-5 items are obsolete; diary is original team content, not a backtranslation target",
"clinician_approval": null,
"reference_comparison": {
"performed_after_translation_frozen": true,
"scope": "28 PCL-5 units",
"reference": "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf",
"reference_page": 3,
"reference_cover_date": "2023-08-29",
"reference_form_footer_date": "2023-08-18",
"independent_review": "pending"
},
"independent_review": "not_performed",
"backtranslation": {
"file": "backtranslation-draft.tsv",
"sha256": "4e8bbcde4d6cb8b613a3f1224fa3963dc81922cabfa1fb1560eeeebf714b3ad5",
"status": "draft_completed_independent_review_pending",
"date": "2026-10-03"
}
}
@@ -0,0 +1,33 @@
"scale" "item_id" "source_page" "russian_text" "backtranslation" "english_original_reference" "review_flag" "review_note" "agreed_russian_text" "clinician" "decision_date" "status"
"trauma_filter" "introduction" "3" "Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства." "Sometimes people experience events that are exceptionally frightening, horrifying, or traumatic. For example: a serious accident or fire; physical or sexual assault or violence; an earthquake or flood; war; witnessing someone being killed or seriously wounded; a loved one dying as a result of murder or suicide." "" "not_compared" "Переведено по PDF; с английским источником фильтра в этом проходе не сверялось." "" "" "" "pending_independent_review"
"trauma_filter" "trauma_filter" "3" "Случалось ли с Вами подобное событие?" "Has an event like this happened to you?" "" "not_compared" "Переведено по PDF; с английским источником фильтра в этом проходе не сверялось." "" "" "" "pending_independent_review"
"trauma_filter" "option_Да" "3" "Да" "Yes" "" "not_compared" "Переведено по PDF; с английским источником фильтра в этом проходе не сверялось." "" "" "" "pending_independent_review"
"trauma_filter" "option_Нет" "3" "Нет" "No" "" "not_compared" "Переведено по PDF; с английским источником фильтра в этом проходе не сверялось." "" "" "" "pending_independent_review"
"pcl5" "instruction" "5" "Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц." "Listed below are problems that people sometimes experience after a very difficult, stressful event. Keeping in mind the event that was most difficult for you, read each item carefully and choose the answer that shows how much this problem bothered you over the past month." "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "Смысл инструкции и месячный период сохранены; «тяжёлый» допускает разные оттенки." "" "" "" "pending_independent_review"
"pcl5" "optional_event" "5" "Ваше самое тяжёлое событие: ______" "The event that was most difficult for you: ______" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "stem" "5" "Насколько сильно за последний месяц Вас беспокоили:" "Over the past month, how much were you bothered by:" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_1" "5" "Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?" "Recurring distressing and unwanted memories of the stressful event?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "«Тяжёлые» менее однозначно передаёт эмоциональное беспокойство, чем disturbing." "" "" "" "pending_independent_review"
"pcl5" "pcl5_2" "6" "Повторяющиеся тяжёлые сны о стрессовом событии?" "Recurring distressing dreams about the stressful event?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "Та же неоднозначность «тяжёлые» применительно к снам." "" "" "" "pending_independent_review"
"pcl5" "pcl5_3" "6" "Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?" "Suddenly feeling or acting as though the stressful event were happening again (as though you were actually back there, experiencing it all over again)?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_4" "6" "Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?" "Becoming very upset when something reminded you of the stressful event?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_5" "6" "Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?" "Strong bodily reactions when something reminded you of the stressful event (for example, a racing heart, difficulty breathing, sweating)?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_6" "6" "Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?" "Avoiding memories, thoughts, or feelings associated with the stressful event?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_7" "6" "Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?" "Avoiding external reminders of the stressful event (for example, people, places, conversations, activities, objects, or situations)?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_8" "6" "Трудности с тем, чтобы вспомнить важные части стрессового события?" "Difficulty remembering important parts of the stressful event?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_9" "6" "Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?" "Strong negative beliefs about yourself, other people, or the world (for example, thoughts such as “I am bad,” “there is something seriously wrong with me,” “no one can be trusted,” “the world is completely dangerous”)?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_10" "6" "Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?" "Blaming yourself or someone else for the stressful event or for what happened afterward?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_11" "6" "Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?" "Strong negative feelings such as fear, horror, anger, guilt, or shame?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_12" "6" "Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?" "Losing interest in activities that used to give you pleasure?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_13" "6" "Ощущение отдалённости или оторванности от других людей?" "Feeling distant or cut off from other people?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_14" "7" "Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?" "Difficulty experiencing positive feelings (for example, being unable to feel joy or love for people close to you)?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_15" "7" "Раздражительность, вспышки гнева или агрессивное поведение?" "Irritability, outbursts of anger, or aggressive behavior?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "priority_review" "Irritable behavior описывает поведение; «раздражительность» может включать внутреннее состояние." "" "" "" "pending_independent_review"
"pcl5" "pcl5_16" "7" "Чрезмерный риск или поступки, которые могут причинить Вам вред?" "Excessive risk-taking or actions that could harm you?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_17" "7" "Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?" "Increased vigilance, wariness, or a feeling that you need to be on guard all the time?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "Добавлено «всё время»; это может усилить требование постоянства настороженности." "" "" "" "pending_independent_review"
"pcl5" "pcl5_18" "7" "Взвинченность или склонность легко вздрагивать?" "Feeling keyed up or being easily startled?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "«Взвинченность» шире jumpy и может пониматься как общее возбуждение." "" "" "" "pending_independent_review"
"pcl5" "pcl5_19" "7" "Трудности с концентрацией внимания?" "Difficulty concentrating?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "pcl5_20" "7" "Трудности с засыпанием или прерывистый сон?" "Difficulty falling asleep or interrupted sleep?" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "review" "«Прерывистый сон» описывает результат, staying asleep — способность сохранять сон." "" "" "" "pending_independent_review"
"pcl5" "option_0" "5" "Совсем нет" "Not at all" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "option_1" "5" "Немного" "A little" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "option_2" "5" "Умеренно" "Moderately" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
"pcl5" "option_3" "5" "Сильно" "Very much" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "priority_review" "«Сильно» вернулось как Very much; оригинальное Quite a bit может задавать иной уровень интенсивности." "" "" "" "pending_independent_review"
"pcl5" "option_4" "5" "Крайне сильно" "Extremely" "https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3)" "no_obvious_difference" "Явного смыслового расхождения при предварительной сверке не выявлено." "" "" "" "pending_independent_review"
1 scale item_id source_page russian_text backtranslation english_original_reference review_flag review_note agreed_russian_text clinician decision_date status
2 trauma_filter introduction 3 Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства. Sometimes people experience events that are exceptionally frightening, horrifying, or traumatic. For example: a serious accident or fire; physical or sexual assault or violence; an earthquake or flood; war; witnessing someone being killed or seriously wounded; a loved one dying as a result of murder or suicide. not_compared Переведено по PDF; с английским источником фильтра в этом проходе не сверялось. pending_independent_review
3 trauma_filter trauma_filter 3 Случалось ли с Вами подобное событие? Has an event like this happened to you? not_compared Переведено по PDF; с английским источником фильтра в этом проходе не сверялось. pending_independent_review
4 trauma_filter option_Да 3 Да Yes not_compared Переведено по PDF; с английским источником фильтра в этом проходе не сверялось. pending_independent_review
5 trauma_filter option_Нет 3 Нет No not_compared Переведено по PDF; с английским источником фильтра в этом проходе не сверялось. pending_independent_review
6 pcl5 instruction 5 Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц. Listed below are problems that people sometimes experience after a very difficult, stressful event. Keeping in mind the event that was most difficult for you, read each item carefully and choose the answer that shows how much this problem bothered you over the past month. https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) review Смысл инструкции и месячный период сохранены; «тяжёлый» допускает разные оттенки. pending_independent_review
7 pcl5 optional_event 5 Ваше самое тяжёлое событие: ______ The event that was most difficult for you: ______ https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
8 pcl5 stem 5 Насколько сильно за последний месяц Вас беспокоили: Over the past month, how much were you bothered by: https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
9 pcl5 pcl5_1 5 Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии? Recurring distressing and unwanted memories of the stressful event? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) review «Тяжёлые» менее однозначно передаёт эмоциональное беспокойство, чем disturbing. pending_independent_review
10 pcl5 pcl5_2 6 Повторяющиеся тяжёлые сны о стрессовом событии? Recurring distressing dreams about the stressful event? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) review Та же неоднозначность «тяжёлые» применительно к снам. pending_independent_review
11 pcl5 pcl5_3 6 Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)? Suddenly feeling or acting as though the stressful event were happening again (as though you were actually back there, experiencing it all over again)? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
12 pcl5 pcl5_4 6 Сильное расстройство, когда что-то напоминало Вам о стрессовом событии? Becoming very upset when something reminded you of the stressful event? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
13 pcl5 pcl5_5 6 Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)? Strong bodily reactions when something reminded you of the stressful event (for example, a racing heart, difficulty breathing, sweating)? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
14 pcl5 pcl5_6 6 Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием? Avoiding memories, thoughts, or feelings associated with the stressful event? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
15 pcl5 pcl5_7 6 Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)? Avoiding external reminders of the stressful event (for example, people, places, conversations, activities, objects, or situations)? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
16 pcl5 pcl5_8 6 Трудности с тем, чтобы вспомнить важные части стрессового события? Difficulty remembering important parts of the stressful event? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
17 pcl5 pcl5_9 6 Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)? Strong negative beliefs about yourself, other people, or the world (for example, thoughts such as “I am bad,” “there is something seriously wrong with me,” “no one can be trusted,” “the world is completely dangerous”)? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
18 pcl5 pcl5_10 6 Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него? Blaming yourself or someone else for the stressful event or for what happened afterward? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
19 pcl5 pcl5_11 6 Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд? Strong negative feelings such as fear, horror, anger, guilt, or shame? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
20 pcl5 pcl5_12 6 Потеря интереса к занятиям, которые раньше приносили Вам удовольствие? Losing interest in activities that used to give you pleasure? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
21 pcl5 pcl5_13 6 Ощущение отдалённости или оторванности от других людей? Feeling distant or cut off from other people? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
22 pcl5 pcl5_14 7 Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)? Difficulty experiencing positive feelings (for example, being unable to feel joy or love for people close to you)? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
23 pcl5 pcl5_15 7 Раздражительность, вспышки гнева или агрессивное поведение? Irritability, outbursts of anger, or aggressive behavior? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) priority_review Irritable behavior описывает поведение; «раздражительность» может включать внутреннее состояние. pending_independent_review
24 pcl5 pcl5_16 7 Чрезмерный риск или поступки, которые могут причинить Вам вред? Excessive risk-taking or actions that could harm you? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
25 pcl5 pcl5_17 7 Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку? Increased vigilance, wariness, or a feeling that you need to be on guard all the time? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) review Добавлено «всё время»; это может усилить требование постоянства настороженности. pending_independent_review
26 pcl5 pcl5_18 7 Взвинченность или склонность легко вздрагивать? Feeling keyed up or being easily startled? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) review «Взвинченность» шире jumpy и может пониматься как общее возбуждение. pending_independent_review
27 pcl5 pcl5_19 7 Трудности с концентрацией внимания? Difficulty concentrating? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
28 pcl5 pcl5_20 7 Трудности с засыпанием или прерывистый сон? Difficulty falling asleep or interrupted sleep? https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) review «Прерывистый сон» описывает результат, staying asleep — способность сохранять сон. pending_independent_review
29 pcl5 option_0 5 Совсем нет Not at all https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
30 pcl5 option_1 5 Немного A little https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
31 pcl5 option_2 5 Умеренно Moderately https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
32 pcl5 option_3 5 Сильно Very much https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) priority_review «Сильно» вернулось как Very much; оригинальное Quite a bit может задавать иной уровень интенсивности. pending_independent_review
33 pcl5 option_4 5 Крайне сильно Extremely https://www.ptsd.va.gov/PTSD/professional/assessment/documents/PCL5_Standard_form.pdf (page 3) no_obvious_difference Явного смыслового расхождения при предварительной сверке не выявлено. pending_independent_review
@@ -0,0 +1,39 @@
"scale" "item_id" "source_page" "russian_text" "english_original_reference" "independent_backtranslation" "concept_difference" "severity" "agreed_russian_text" "clinician" "decision_date" "status"
"pcptsd5" "introduction" "3" "Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства." "" "" "" "" "" "" "" "pending"
"pcptsd5" "trauma_filter" "3" "Случалось ли с Вами подобное событие?" "" "" "" "" "" "" "" "pending"
"pcptsd5" "instruction" "3" "За последний месяц Вы…" "" "" "" "" "" "" "" "pending"
"pcptsd5" "pcptsd5_1" "4" "…видели кошмары об этом событии (событиях) или думали о нём, когда не хотели этого?" "" "" "" "" "" "" "" "pending"
"pcptsd5" "pcptsd5_2" "4" "…изо всех сил старались не думать о событии (событиях) или специально избегали ситуаций, которые о нём напоминали?" "" "" "" "" "" "" "" "pending"
"pcptsd5" "pcptsd5_3" "4" "…были постоянно настороже, начеку или легко вздрагивали?" "" "" "" "" "" "" "" "pending"
"pcptsd5" "pcptsd5_4" "4" "…чувствовали оцепенение или отстранённость от людей, занятий или того, что Вас окружает?" "" "" "" "" "" "" "" "pending"
"pcptsd5" "pcptsd5_5" "4" "…чувствовали вину или не могли перестать обвинять себя или других в событии (событиях) или в проблемах, которые оно могло вызвать?" "" "" "" "" "" "" "" "pending"
"pcptsd5" "option_Да" "3–4" "Да" "" "" "" "" "" "" "" "pending"
"pcptsd5" "option_Нет" "3–4" "Нет" "" "" "" "" "" "" "" "pending"
"pcl5" "instruction" "5" "Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц." "" "" "" "" "" "" "" "pending"
"pcl5" "optional_event" "5" "Ваше самое тяжёлое событие: ______" "" "" "" "" "" "" "" "pending"
"pcl5" "stem" "5" "Насколько сильно за последний месяц Вас беспокоили:" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_1" "5" "Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_2" "5" "Повторяющиеся тяжёлые сны о стрессовом событии?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_3" "5" "Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_4" "5" "Сильное расстройство, когда что-то напоминало Вам о стрессовом событии?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_5" "5" "Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_6" "5" "Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_7" "6" "Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_8" "6" "Трудности с тем, чтобы вспомнить важные части стрессового события?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_9" "6" "Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_10" "6" "Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_11" "6" "Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_12" "6" "Потеря интереса к занятиям, которые раньше приносили Вам удовольствие?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_13" "6" "Ощущение отдалённости или оторванности от других людей?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_14" "6" "Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_15" "6" "Раздражительность, вспышки гнева или агрессивное поведение?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_16" "6" "Чрезмерный риск или поступки, которые могут причинить Вам вред?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_17" "6" "Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_18" "6" "Взвинченность или склонность легко вздрагивать?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_19" "6" "Трудности с концентрацией внимания?" "" "" "" "" "" "" "" "pending"
"pcl5" "pcl5_20" "7" "Трудности с засыпанием или прерывистый сон?" "" "" "" "" "" "" "" "pending"
"pcl5" "option_0" "5" "Совсем нет" "" "" "" "" "" "" "" "pending"
"pcl5" "option_1" "5" "Немного" "" "" "" "" "" "" "" "pending"
"pcl5" "option_2" "5" "Умеренно" "" "" "" "" "" "" "" "pending"
"pcl5" "option_3" "5" "Сильно" "" "" "" "" "" "" "" "pending"
"pcl5" "option_4" "5" "Крайне сильно" "" "" "" "" "" "" "" "pending"
1 scale item_id source_page russian_text english_original_reference independent_backtranslation concept_difference severity agreed_russian_text clinician decision_date status
2 pcptsd5 introduction 3 Иногда с людьми происходят события, которые оказываются необычайно пугающими, ужасными или травмирующими. Например: серьёзная авария или пожар; физическое или сексуальное нападение или насилие; землетрясение или наводнение; война; Вы видели, как кого-то убили или тяжело ранили; близкий человек погиб в результате убийства или самоубийства. pending
3 pcptsd5 trauma_filter 3 Случалось ли с Вами подобное событие? pending
4 pcptsd5 instruction 3 За последний месяц Вы… pending
5 pcptsd5 pcptsd5_1 4 …видели кошмары об этом событии (событиях) или думали о нём, когда не хотели этого? pending
6 pcptsd5 pcptsd5_2 4 …изо всех сил старались не думать о событии (событиях) или специально избегали ситуаций, которые о нём напоминали? pending
7 pcptsd5 pcptsd5_3 4 …были постоянно настороже, начеку или легко вздрагивали? pending
8 pcptsd5 pcptsd5_4 4 …чувствовали оцепенение или отстранённость от людей, занятий или того, что Вас окружает? pending
9 pcptsd5 pcptsd5_5 4 …чувствовали вину или не могли перестать обвинять себя или других в событии (событиях) или в проблемах, которые оно могло вызвать? pending
10 pcptsd5 option_Да 3–4 Да pending
11 pcptsd5 option_Нет 3–4 Нет pending
12 pcl5 instruction 5 Ниже перечислены проблемы, которые иногда возникают у людей после очень тяжёлого, стрессового события. Держа в уме самое тяжёлое для Вас событие, внимательно прочитайте каждый пункт и выберите вариант, который показывает, насколько сильно эта проблема беспокоила Вас за последний месяц. pending
13 pcl5 optional_event 5 Ваше самое тяжёлое событие: ______ pending
14 pcl5 stem 5 Насколько сильно за последний месяц Вас беспокоили: pending
15 pcl5 pcl5_1 5 Повторяющиеся тяжёлые и нежелательные воспоминания о стрессовом событии? pending
16 pcl5 pcl5_2 5 Повторяющиеся тяжёлые сны о стрессовом событии? pending
17 pcl5 pcl5_3 5 Внезапное ощущение или поведение, как будто стрессовое событие происходит снова (как будто Вы действительно вернулись туда и переживаете его заново)? pending
18 pcl5 pcl5_4 5 Сильное расстройство, когда что-то напоминало Вам о стрессовом событии? pending
19 pcl5 pcl5_5 5 Сильные телесные реакции, когда что-то напоминало Вам о стрессовом событии (например, сердцебиение, затруднённое дыхание, потливость)? pending
20 pcl5 pcl5_6 5 Избегание воспоминаний, мыслей или чувств, связанных со стрессовым событием? pending
21 pcl5 pcl5_7 6 Избегание внешних напоминаний о стрессовом событии (например, людей, мест, разговоров, занятий, предметов или ситуаций)? pending
22 pcl5 pcl5_8 6 Трудности с тем, чтобы вспомнить важные части стрессового события? pending
23 pcl5 pcl5_9 6 Сильные негативные убеждения о себе, других людях или мире (например, мысли «я плохой», «со мной что-то серьёзно не так», «никому нельзя доверять», «мир абсолютно опасен»)? pending
24 pcl5 pcl5_10 6 Обвинение себя или кого-то другого в стрессовом событии или в том, что произошло после него? pending
25 pcl5 pcl5_11 6 Сильные негативные чувства, такие как страх, ужас, гнев, вина или стыд? pending
26 pcl5 pcl5_12 6 Потеря интереса к занятиям, которые раньше приносили Вам удовольствие? pending
27 pcl5 pcl5_13 6 Ощущение отдалённости или оторванности от других людей? pending
28 pcl5 pcl5_14 6 Трудности с тем, чтобы испытывать положительные чувства (например, неспособность чувствовать радость или любовь к близким людям)? pending
29 pcl5 pcl5_15 6 Раздражительность, вспышки гнева или агрессивное поведение? pending
30 pcl5 pcl5_16 6 Чрезмерный риск или поступки, которые могут причинить Вам вред? pending
31 pcl5 pcl5_17 6 Повышенная бдительность, настороженность или ощущение, что нужно всё время быть начеку? pending
32 pcl5 pcl5_18 6 Взвинченность или склонность легко вздрагивать? pending
33 pcl5 pcl5_19 6 Трудности с концентрацией внимания? pending
34 pcl5 pcl5_20 7 Трудности с засыпанием или прерывистый сон? pending
35 pcl5 option_0 5 Совсем нет pending
36 pcl5 option_1 5 Немного pending
37 pcl5 option_2 5 Умеренно pending
38 pcl5 option_3 5 Сильно pending
39 pcl5 option_4 5 Крайне сильно pending
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
{
"source_url": "https://emdr.ru/sites/default/files/2024-11/%D0%9A%D0%A0753%20%282%29.pdf",
"publisher": "Российское общество психиатров",
"guideline": "753, 2023",
"pdf": "tsq-guideline-2023.pdf",
"sha256": "d1148603af1114aa588ef206dc1cb00613acd9f19f150983d24bbcc9d50b3a5a",
"pages": [
129,
130
],
"current_edition_verified": false,
"rop_download_6016": "Contains a draft with year 202_; not treated as approved current edition."
}
+58
View File
@@ -0,0 +1,58 @@
"""Protect the reviewed PDF transcription and its connection to Recent scoring."""
import hashlib
import json
from itertools import product
from pathlib import Path
from app.core.cssrs_content import load_cssrs_russian
from app.core.questionnaire import score_cssrs_recent
CLINICAL = Path(__file__).resolve().parents[1] / "docs" / "clinical"
def test_source_hashes_and_complete_transcription():
form = load_cssrs_russian()
manifest = json.loads((CLINICAL / "review-status.json").read_text())["cssrs_russian_pdf"]
assert hashlib.sha256((CLINICAL / manifest["file"]).read_bytes()).hexdigest() == manifest["sha256"]
source = (CLINICAL / manifest["source_text"]).read_bytes()
assert hashlib.sha256(source).hexdigest() == manifest["source_text_sha256"]
assert form.version == manifest["content_version"]
# Reconstruct the complete extracted page, including numbering, guidance,
# original typos, answer order and the color legend. No wording is dropped.
blocks = [form.title, form.subtitle, form.period_label_original, form.initial_instruction]
blocks.extend(option.label for option in form.options)
for index, item in enumerate(form.items):
blocks.append(f"{index + 1}. {item.text}" if index < 6 else item.text)
if item.interviewer_guidance:
blocks.append(item.interviewer_guidance)
if item.id == "q2":
blocks.append(form.branch_instruction)
blocks.extend(["Low Risk", "Moderate Risk", "High Risk"])
assert " ".join(blocks) == source.decode().strip()
assert [item.recall_period for item in form.items] == ["past_month"] * 5 + ["lifetime", "past_3_months"]
def test_all_source_branches_and_color_categories_match_scorer():
form = load_cssrs_russian()
colors = {item.id: item.yes_risk for item in form.items}
assert list(colors.values()) == ["low", "low", "moderate", "high", "high", "moderate", "high"]
levels = ["none", "low", "moderate", "high"]
for values in product((0, 1), repeat=7):
all_answers = dict(zip(colors, values, strict=True))
asked = ["q1", "q2"]
if all_answers["q2"]:
asked.extend(["q3", "q4", "q5"])
asked.append("q6_lifetime")
if all_answers["q6_lifetime"]:
asked.append("q6_recent")
answers = {}
for item_id in asked:
partial = score_cssrs_recent(answers)
assert not partial.complete
assert partial.missing[0] == item_id
answers[item_id] = all_answers[item_id]
result = score_cssrs_recent(answers)
expected = max((levels.index(colors[key]) for key, value in answers.items() if value), default=0)
assert result.complete and result.risk == levels[expected]
assert result.required == tuple(asked)
+3
View File
@@ -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
+485
View File
@@ -0,0 +1,485 @@
"""Dialog engine (#10): consent, age gate, crisis without LLM, C-SSRS on POSSIBLE, scale sessions, revocation.
Two in-memory SQLite databases (identity / clinical), offline mock LLM wrapped in a spy that records every task
call, and a fake monitoring service (lane B stub) that records calls and returns scripted actions.
"""
from collections.abc import AsyncIterator
from typing import Any
import pytest
from sqlalchemy import select
from app.config import get_settings
from app.core import clock
from app.core.rules import Action
from app.core.safety import crisis_reply
from app.core.types import AlertLevel, ScrubbedText
from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.db.clinical import ItemResponse, Message, SafetyPlan
from app.dialog.contracts import Event, Reply, get_engine
from app.dialog.engine import Engine
from app.dialog.fsm import Ctx, Step, on_enter
from app.dialog.texts import load_dialog_texts
from app.integrations.base import VerifiedPerson, integrations
from app.llm.gateway import LLMGateway
from app.llm.mock import MockTasks
from app.llm.schemas import RiskVerdict
TOKEN = "a" * 32
TEXTS = load_dialog_texts()
MEM = "sqlite+aiosqlite:///:memory:"
class SpyTasks(MockTasks):
"""Offline mock that records which LLM tasks were called (to prove the crisis path never calls the LLM)."""
def __init__(self, risk: str | None = None) -> None:
self.calls: list[str] = []
self._risk = risk
async def extract_item(self, scale_item_id: str, item_text: str, max_score: int, answer: ScrubbedText) -> Any:
self.calls.append("extract_item")
return await super().extract_item(scale_item_id, item_text, max_score, answer)
async def extract_checkin(self, text: ScrubbedText) -> Any:
self.calls.append("extract_checkin")
return await super().extract_checkin(text)
async def risk_classify(self, text: ScrubbedText) -> RiskVerdict | None:
self.calls.append("risk_classify")
if self._risk is not None:
return RiskVerdict(level=self._risk, confidence=0.9) # type: ignore[arg-type]
return await super().risk_classify(text)
async def phrase(self, intent: str, payload: ScrubbedText, base_text: str) -> str | None:
self.calls.append("phrase")
return base_text
class FakeMonitoring:
def __init__(self, actions: list[Action] | None = None, in_crisis: bool = True) -> None:
self.calls = 0
self.actions = actions or []
self.in_crisis = in_crisis
async def after_data_change(self, token: str) -> list[Action]:
self.calls += 1
out, self.actions = self.actions, []
return out
async def is_in_crisis(self, token: str) -> bool:
return self.in_crisis
@pytest.fixture
async def dbs() -> AsyncIterator[None]:
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with session.get_identity_session() as s:
await identity_repo.create_patient(
s,
token=TOKEN,
display_name="Тестовый Пациент",
snils="000-000-000 00",
clinic_id="c1",
doctor_id="doc-test",
now=clock.now(),
birth_year=1992,
sex="m",
)
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-test", modules=[], sex="m")
yield
await session.configure() # back to default URLs for other test modules
@pytest.fixture
def spy() -> SpyTasks:
return SpyTasks()
@pytest.fixture
def mon() -> FakeMonitoring:
return FakeMonitoring()
@pytest.fixture
def engine(spy: SpyTasks, mon: FakeMonitoring) -> Engine:
return Engine(gateway=LLMGateway(spy), monitoring=mon)
# ----------------------------------------------------------------------------------------------- helpers
async def text(e: Engine, t: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="text", text=t))
async def press(e: Engine, data: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="button", button=data))
async def system(e: Engine, kind: str, **params: Any) -> list[Reply]:
return await e.handle(TOKEN, Event(type="system", system=kind, params=params)) # type: ignore[arg-type]
async def set_state(state: str, **data: Any) -> None:
async with session.get_clinical_session() as s:
await repo.set_dialog_state(s, token=TOKEN, state=state, data={"consent": True, **data}, now=clock.now())
async def get_state() -> tuple[str, dict[str, Any]]:
async with session.get_clinical_session() as s:
row = await repo.get_dialog_state(s, TOKEN)
assert row is not None
return row.state, dict(row.data)
async def stored_messages() -> list[Message]:
async with session.get_clinical_session() as s:
return list((await s.scalars(select(Message).where(Message.token == TOKEN).order_by(Message.id))).all())
async def identity_patient() -> Any:
async with session.get_identity_session() as s:
return await identity_repo.get_patient(s, TOKEN)
async def set_birth_year(year: int | None) -> None:
async with session.get_identity_session() as s:
patient = await identity_repo.get_patient(s, TOKEN)
assert patient is not None
patient.birth_year = year
def buttons(reply: Reply) -> list[str]:
return [b.data for row in reply.buttons for b in row]
# A fake module onboarding entry point, to check the hand-over without depending on #36.
@on_enter("zztest.onboarding.start")
async def _zz_onboarding(ctx: Ctx) -> Step:
return Step(replies=[Reply(text="zz onboarding")])
# ----------------------------------------------------------------------------------------------- registration
async def test_get_engine_returns_real_engine() -> None:
assert isinstance(get_engine(), Engine)
async def test_consent_accept_goes_to_idle(dbs: None, engine: Engine) -> None:
r = await engine.handle(TOKEN, Event(type="start"))
assert "152-ФЗ" in r[0].text and buttons(r[0]) == ["consent:yes", "consent:no"]
assert (await get_state())[0] == "consent"
r = await press(engine, "consent:yes")
state, data = await get_state()
# Birth year 1992 is known from registration → adult without asking; no module flow → idle.
assert state == "idle" and data["consent"] is True
assert r[-1].text.startswith(TEXTS.idle.welcome[:20]) and "consent:revoke" in buttons(r[-1])
patient = await identity_patient()
assert patient.consent_at is not None and patient.consent_ver == TEXTS.consent.version
async def test_connect_code_verified(dbs: None, engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None:
class Ident:
async def verify(self, auth_code: str) -> VerifiedPerson | None:
return VerifiedPerson(token=TOKEN, doctor_id="doc-test") if auth_code == "GOOD" else None
monkeypatch.setattr(integrations, "identity", Ident())
r = await engine.handle(TOKEN, Event(type="start", params={"code": "BAD"}))
assert r[0].text == TEXTS.consent.code_invalid and (await get_state())[0] == "new"
r = await engine.handle(TOKEN, Event(type="start", params={"code": "GOOD"}))
assert buttons(r[0]) == ["consent:yes", "consent:no"]
async def test_consent_refused_no_processing(dbs: None, engine: Engine, spy: SpyTasks) -> None:
await engine.handle(TOKEN, Event(type="start"))
r = await press(engine, "consent:no")
assert r[0].text == TEXTS.consent.refused and (await get_state())[0] == "refused"
r = await text(engine, "мне плохо, не сплю, позвоните 89161234567")
assert r[0].text == TEXTS.consent.refused
assert spy.calls == [] # nothing reached the LLM
assert await stored_messages() == [] # nothing stored without consent
assert (await identity_patient()).consent_at is None
r = await press(engine, "consent:restart")
assert buttons(r[0]) == ["consent:yes", "consent:no"]
async def test_minor_gets_child_helpline_and_stop(dbs: None, engine: Engine, spy: SpyTasks) -> None:
await set_birth_year(None)
await engine.handle(TOKEN, Event(type="start"))
r = await press(engine, "consent:yes")
assert buttons(r[-1]) == ["age:adult", "age:minor"]
r = await text(engine, "2012")
assert "8-800-2000-122" in r[0].text
state, data = await get_state()
assert state == "minor" and data["consent"] is False
assert (await identity_patient()).revoked_at is not None # a minor's consent is withdrawn
calls = len(spy.calls)
r = await text(engine, "а что дальше?")
assert "8-800-2000-122" in r[0].text and len(spy.calls) == calls
async def test_age_gate_button_adult(dbs: None, engine: Engine) -> None:
await set_birth_year(None)
await engine.handle(TOKEN, Event(type="start"))
await press(engine, "consent:yes")
r = await text(engine, "не скажу")
assert r[0].text.startswith(TEXTS.age_gate.unclear)
await press(engine, "age:adult")
assert (await get_state())[0] == "idle"
async def test_hand_over_to_module_onboarding(dbs: None, engine: Engine) -> None:
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, modules=["zztest"])
await engine.handle(TOKEN, Event(type="start"))
r = await press(engine, "consent:yes")
assert r[-1].text == "zz onboarding"
assert (await get_state())[0] == "zztest.onboarding.start"
# ----------------------------------------------------------------------------------------------- safety
async def test_acute_sends_script_without_any_llm_call(
dbs: None, engine: Engine, spy: SpyTasks, mon: FakeMonitoring
) -> None:
await set_state("idle")
r = await text(engine, "хочу покончить с собой")
assert len(r) == 1 and r[0].kind == "crisis"
assert r[0].text == crisis_reply(["suicide"]).text # verbatim fixed script
assert spy.calls == [] # lexicon ACUTE → risk_classify is not even called
state, data = await get_state()
assert state == "crisis" and data["crisis"]["open"] is True
event = data["safety_events"][-1]
assert event["level"] == "acute" and event["categories"] == ["suicide"] and event["handled"] == "crisis_script"
assert mon.calls == 1 # lane B gets the chance to escalate
async def test_repeat_crisis_does_not_duplicate_script(dbs: None, engine: Engine, spy: SpyTasks) -> None:
await set_state("idle")
script = (await text(engine, "хочу покончить с собой"))[0].text
r = await text(engine, "не хочу жить")
assert r[0].kind == "crisis" and r[0].text != script
assert r[0].text.startswith(TEXTS.crisis.repeat) and "112" in r[0].text
assert spy.calls == []
r = await text(engine, "ладно")
assert r[0].text == TEXTS.crisis.hold
_, data = await get_state()
assert [e["handled"] for e in data["safety_events"]] == ["crisis_script", "crisis_repeat"]
async def test_doctor_ack_leaves_crisis(dbs: None, engine: Engine) -> None:
await set_state("idle")
await text(engine, "хочу покончить с собой")
r = await system(engine, "doctor_ack")
assert r[0].text == TEXTS.crisis.closed
state, data = await get_state()
assert state == "idle" and data["crisis"]["open"] is False
# A new acute message after the crisis was closed gets the full script again.
r = await text(engine, "хочу покончить с собой")
assert r[0].text == crisis_reply(["suicide"]).text
async def test_crisis_left_when_monitoring_says_resolved(dbs: None, spy: SpyTasks) -> None:
mon = FakeMonitoring(in_crisis=False)
e = Engine(gateway=LLMGateway(spy), monitoring=mon)
await set_state("crisis", crisis={"open": True})
r = await text(e, "мне получше")
assert r[0].text == TEXTS.crisis.closed and (await get_state())[0] == "idle"
async def test_possible_starts_cssrs_and_returns(dbs: None, engine: Engine, spy: SpyTasks) -> None:
await set_state("idle")
r = await text(engine, "всё бессмысленно, устал от всего")
assert "risk_classify" in spy.calls
assert (await get_state())[0] == "scale.cssrs_screen"
assert r[-1].kind == "question" and [b.label for row in r[-1].buttons for b in row] == ["Нет", "Да"]
assert TEXTS.scale.risk_screen_intro in r[-1].text
r = await text(engine, "нет") # C-SSRS is buttons-only: free text is never sent to extract_item
assert r[0].text.startswith(TEXTS.scale.buttons_only) and "extract_item" not in spy.calls
for i in range(1, 7):
r = await press(engine, f"scale:cssrs_screen:cssrs_{i}:0")
state, data = await get_state()
assert state == "idle" and "cssrs_screen" not in data.get("scales", {})
assert r[0].text == TEXTS.scale.done
async with session.get_clinical_session() as s:
(res,) = await repo.list_scale_results(s, TOKEN, "cssrs_screen")
assert res.severity == "none" and res.scheduled is False and res.complete
assert data["safety_events"][-1]["handled"] == "risk_screen"
async def test_cssrs_high_goes_to_crisis(dbs: None, engine: Engine) -> None:
await set_state("idle")
await text(engine, "всё бессмысленно, устал от всего")
await press(engine, "scale:cssrs_screen:cssrs_1:1")
await press(engine, "scale:cssrs_screen:cssrs_2:1")
await press(engine, "scale:cssrs_screen:cssrs_3:0")
r = await press(engine, "scale:cssrs_screen:cssrs_4:1") # intent → high: the session ends at once
assert any(x.kind == "crisis" and x.text == crisis_reply([]).text for x in r)
assert (await get_state())[0] == "crisis"
async def test_llm_signal_can_raise_risk(dbs: None, mon: FakeMonitoring) -> None:
spy = SpyTasks(risk="possible")
e = Engine(gateway=LLMGateway(spy), monitoring=mon)
await set_state("idle")
await text(e, "обычный день") # lexicon NONE, LLM says possible → max() = POSSIBLE
state, data = await get_state()
assert state == "scale.cssrs_screen" and data["safety_events"][-1]["source"] == "llm"
# ----------------------------------------------------------------------------------------------- scale session
async def test_scale_session_buttons_free_text_and_clarify(dbs: None, engine: Engine, spy: SpyTasks) -> None:
await set_state("idle")
r = await system(engine, "scale_due", scale="phq9", scheduled=True)
assert (await get_state())[0] == "scale.phq9"
assert r[0].kind == "question" and len(buttons(r[0])) == 4 and "1/9" in r[0].text
await press(engine, "scale:phq9:phq9_1:1")
r = await text(engine, "почти каждый день") # mock extract: confident score 3
assert "3/9" in r[0].text
r = await text(engine, "ну такое") # no cue → low confidence → clarify, nothing stored
assert r[0].text.startswith(TEXTS.scale.clarify) and "3/9" in r[0].text
r = await press(engine, "scale:phq9:phq9_1:2") # stale button from an earlier question → re-ask
assert "3/9" in r[0].text
for i in range(3, 9):
await press(engine, f"scale:phq9:phq9_{i}:0")
r = await text(engine, "нет") # item 9 is buttons-only
assert r[0].text.startswith(TEXTS.scale.buttons_only)
assert spy.calls.count("extract_item") == 2
r = await press(engine, "scale:phq9:phq9_9:0")
assert r[0].text == TEXTS.scale.done and (await get_state())[0] == "idle"
async with session.get_clinical_session() as s:
(res,) = await repo.list_scale_results(s, TOKEN, "phq9")
items = (await s.scalars(select(ItemResponse).order_by(ItemResponse.id))).all()
assert (res.total, res.item9, res.complete, res.scheduled) == (4, 0, True, True)
assert [(i.item_id, i.source) for i in items[:2]] == [("phq9_1", "button"), ("phq9_2", "llm")]
assert items[1].score == 3 and items[1].confidence is not None
async def test_phq9_item9_positive_crisis_then_cssrs(dbs: None, engine: Engine, spy: SpyTasks) -> None:
await set_state("idle")
await system(engine, "scale_due", scale="phq9", scheduled=False)
for i in range(1, 9):
await press(engine, f"scale:phq9:phq9_{i}:0")
r = await press(engine, "scale:phq9:phq9_9:2")
kinds = [x.kind for x in r]
assert "crisis" in kinds and r[kinds.index("crisis")].text == crisis_reply([]).text
assert r[-1].kind == "question" and "cssrs" not in r[-1].text # C-SSRS q1 asked after the script
state, data = await get_state()
assert state == "scale.cssrs_screen" and data["crisis"]["open"] is True
assert data["scales"]["cssrs_screen"]["return_state"] == "crisis"
async with session.get_clinical_session() as s:
(res,) = await repo.list_scale_results(s, TOKEN, "phq9")
assert res.item9 == 2 and res.scheduled is False and res.flags == {"phq9_item9_positive": True}
assert "extract_item" not in spy.calls
# A repeated acute phrase during the C-SSRS: short text, no second script, the screen continues.
r = await text(engine, "не хочу жить")
assert r[0].text.startswith(TEXTS.crisis.repeat) and (await get_state())[0] == "scale.cssrs_screen"
# ----------------------------------------------------------------------------------------------- monitoring actions
async def test_monitoring_actions_executed(dbs: None, spy: SpyTasks) -> None:
mon = FakeMonitoring(
actions=[
Action(type="alert", level=AlertLevel.HIGH, code="x"),
Action(type="show_safety_plan", code="phq9_item9"),
Action(type="schedule_scale", code="mood_low_3d", params={"scale": "phq9", "scheduled": False}),
]
)
e = Engine(gateway=LLMGateway(spy), monitoring=mon)
async with session.get_clinical_session() as s:
s.add(SafetyPlan(token=TOKEN, steps=["Позвонить брату Олегу", "Прогулка"], updated_at=clock.now()))
await set_state("idle")
r = await press(e, "menu:contact_doctor") # records a contact request → monitoring runs
assert r[0].text == TEXTS.idle.contact_doctor_ack
plan = next(x for x in r if x.kind == "safety_plan")
assert "Позвонить брату Олегу" in plan.text
assert r[-1].kind == "question" # unscheduled PHQ-9 started right away (idle = resting)
state, data = await get_state()
assert state == "scale.phq9" and data["scales"]["phq9"]["scheduled"] is False
assert data["contact_requests"]
# Stored outbound texts are scrubbed (the plan names a relative).
assert not any("Олег" in m.text_scrubbed for m in await stored_messages())
async def test_suggest_visit_and_med_info_phrased(dbs: None, spy: SpyTasks) -> None:
mon = FakeMonitoring(
actions=[
Action(type="med_info", code="alcohol_3d", params={"topic": "alcohol_interaction"}),
Action(type="suggest_visit", code="alcohol_3d", params={"reason": "alcohol"}),
]
)
e = Engine(gateway=LLMGateway(spy), monitoring=mon)
await set_state("idle")
r = await press(e, "menu:contact_doctor")
assert any("алкогол" in x.text for x in r) and buttons(r[-1]) == ["menu:visit"]
# ------------------------------------------------------------------------------------ consent revocation, privacy
async def test_revoke_consent_then_silence(dbs: None, engine: Engine, spy: SpyTasks) -> None:
await set_state("idle")
r = await press(engine, "consent:revoke")
assert r[0].text == TEXTS.revoked.confirm
state, data = await get_state()
assert state == "consent_revoked" and data["consent"] is False and data["delete_requested"] is True
assert (await identity_patient()).revoked_at is not None
before = len(await stored_messages())
assert await text(engine, "привет, как дела?") == []
assert await system(engine, "daily_checkin") == []
assert spy.calls == [] and len(await stored_messages()) == before
r = await engine.handle(TOKEN, Event(type="start")) # coming back = asking for consent again
assert buttons(r[0]) == ["consent:yes", "consent:no"]
async def test_revoke_by_text_command(dbs: None, engine: Engine) -> None:
await set_state("idle")
r = await text(engine, "Отозвать согласие")
assert r[0].text == TEXTS.revoked.confirm and (await get_state())[0] == "consent_revoked"
async def test_messages_stored_scrubbed(dbs: None, engine: Engine) -> None:
await set_state("idle")
r = await text(engine, "Перезвоните мне на 8 916 123-45-67, сегодня обычный день")
assert r and buttons(r[0]) # neutral support reply with the idle menu
msgs = await stored_messages()
inbound = [m.text_scrubbed for m in msgs if m.direction == "in"]
assert inbound and "[PHONE]" in inbound[0]
assert not any("123-45-67" in m.text_scrubbed or "916" in m.text_scrubbed for m in msgs)
assert any(m.direction == "out" for m in msgs)
async def test_db_unavailable_still_answers_crisis(engine: Engine, tmp_path: Any) -> None:
await session.configure(identity_url=MEM, clinical_url=f"sqlite+aiosqlite:///{tmp_path}/missing/dir/x.db")
try:
r = await text(engine, "хочу покончить с собой")
assert r[0].kind == "crisis" and "112" in r[0].text
assert crisis_reply(["suicide"]).doctor_notified not in r[0].text # nobody could be notified
r = await text(engine, "привет")
assert r[0].text == TEXTS.unavailable
finally:
await session.configure()
def test_dialog_texts_avoid_forbidden_words() -> None:
raw = (get_settings().config_dir / "texts" / "dialog.yaml").read_text(encoding="utf-8").lower()
body = "\n".join(line for line in raw.splitlines() if not line.lstrip().startswith("#"))
for word in ("учёт", "учет", "контрол", "надзор", "диагноз:"):
assert word not in body
assert "152-фз" in body and "8-800-2000-122" in body
+407
View File
@@ -0,0 +1,407 @@
"""Synthetic released-config integration tests. Production approvals stay empty."""
import asyncio
import shutil
from datetime import UTC, datetime, timedelta
import pytest
import yaml
from sqlalchemy import select
from app.config import get_settings
from app.core import clock
from app.core.scales import clear_cache
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask
from app.dialog.contracts import Event
from app.dialog.engine import Engine
from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations
from app.llm.gateway import LLMGateway
from app.llm.mock import MockTasks
from app.scheduler import jobs
from app.services import questionnaire as service
TOKEN = "b" * 32
NOW = datetime(2026, 10, 3, 17, tzinfo=UTC)
@pytest.fixture
async def released(tmp_path, monkeypatch):
config = tmp_path / "config"
shutil.copytree(get_settings().config_dir, config)
for name in ("questionnaire", "intake_runtime"):
path = config / f"{name}.yaml"
data = yaml.safe_load(path.read_text())
approval = dict(
reviewer="Synthetic test",
decision_reference="fixture-only",
approved_at=NOW.isoformat(),
policy_version=data["version"],
)
data["approval"] = approval
if name == "questionnaire":
data.update(cssrs_positive_policy="graded", rus_audit_s_expand_at=1)
else:
data.update(enabled=True, content_review=approval, contact_review=approval)
path.write_text(yaml.safe_dump(data, allow_unicode=True))
monkeypatch.setattr(get_settings(), "config_dir", config)
monkeypatch.setattr(clock, "now", lambda: NOW)
monkeypatch.setattr(integrations, "notifier", MemoryNotifier())
monkeypatch.setattr(integrations, "outbox", MemoryOutbox())
clear_cache()
await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:")
await init_db()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="synthetic-doctor", modules=["veteran"], sex="m")
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=NOW)
yield Engine(gateway=LLMGateway(MockTasks()))
await session.configure()
clear_cache()
async def rows(cls=QuestionnaireRecord):
async with session.get_clinical_session() as s:
return list((await s.scalars(select(cls))).all())
async def press(e, data):
return await e.handle(TOKEN, Event(type="button", button=data))
def answer_button(replies, choices=None):
choices = choices or {}
for reply in reversed(replies):
for line in reply.buttons:
for b in line:
if b.data.startswith("iq:"):
item = b.data.split(":")[2]
index = choices.get(item, 1 if item.startswith("cssrs_") else 0)
for line2 in reply.buttons:
for other in line2:
if other.data.endswith(":" + str(index)) and other.data.startswith("iq:"):
return other.data
return None
async def complete_intake(e, choices=None):
replies = await press(e, "intake:start")
for _ in range(100):
button = answer_button(replies, choices)
if button is None:
return replies
replies = await press(e, button)
raise AssertionError("intake did not terminate")
async def test_minimal_intake_raw_data_not_imputed_and_restart(released):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
(row,) = await rows()
assert row.status == "complete"
assert row.answers["trauma"] == 0 and not any(k.startswith("tsq_") for k in row.answers)
assert row.details["totals"]["tsq"]["total"] is None
assert row.details["totals"]["phq9"]["total"] is None
assert row.details["totals"]["rus_audit_s"]["total"] == 0
assert row.details["totals"]["cssrs_recent"]["risk"] == "none"
assert row.details["decision"]["primary_route"] == "self_help"
async def test_pause_restart_and_duplicate_callbacks(released):
replies = await press(released, "intake:start")
old = answer_button(replies)
await asyncio.gather(press(released, old), press(released, old))
(row,) = await rows()
assert row.answers == {"cssrs_q1": 0}
await press(released, "intake:pause")
restarted = Engine(gateway=LLMGateway(MockTasks()))
replies = await press(restarted, "intake:resume")
assert "cssrs_q2" in answer_button(replies)
await press(restarted, old)
assert (await rows())[0].answers == {"cssrs_q1": 0}
assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line)
@pytest.mark.parametrize("signal", ["help", "phq", "cssrs"])
async def test_immediate_stop_preserves_partial_answers(released, signal):
replies = await press(released, "intake:start")
if signal == "help":
replies = await press(released, "intake:help")
elif signal == "cssrs":
for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"):
replies = await press(released, answer_button(replies, {item: 0}))
else:
replies = await complete_existing(released, replies, {"trauma": 1, "phq9_1": 2, "phq9_9": 1})
assert any(r.kind == "crisis" for r in replies)
(row,) = await rows()
assert row.status == "interrupted" and row.completed_at is None
notices = await rows(QuestionnaireNotice)
assert len(notices) == 1 and notices[0].status == "test_sink"
assert "уведомлён" not in replies[0].text
async def complete_existing(e, replies, choices):
for _ in range(100):
button = answer_button(replies, choices)
if not button:
return replies
replies = await press(e, button)
raise AssertionError("not terminated")
async def test_full_tsq_expands_at_six_and_carries_alcohol_answers(released):
choices = {"tsq_" + str(i): 0 if i <= 6 else 1 for i in range(1, 11)}
choices.update(rus_audit_3=1, rus_audit_1=1, tbi=1, psychotropics=1)
await complete_intake(released, choices)
(row,) = await rows()
assert row.status == "complete"
assert row.details["totals"]["tsq"]["total"] == 6
assert row.details["totals"]["pcl5"]["total"] == 0
assert row.details["totals"]["rus_audit"]["complete"]
assert row.answers["rus_audit_3"] == 1
assert len([k for k in row.answers if k.startswith("rus_audit_")]) == 10
async def test_alcohol_conflict_keeps_positive_short_answer(released):
await complete_intake(released, {"trauma": 1, "rus_audit_3": 1, "rus_audit_1": 0})
(row,) = await rows()
assert row.status == "review_required" and row.answers["rus_audit_3"] == 1
assert row.details["stop_reason"] == "answer_conflict"
async def test_low_risk_shows_safety_plan_but_moderate_stops(released):
replies = await press(released, "intake:start")
replies = await press(released, answer_button(replies, {"cssrs_q1": 0}))
replies = await press(released, answer_button(replies))
replies = await press(released, answer_button(replies))
assert any(r.kind == "safety_plan" for r in replies)
assert "phq9_1" in answer_button(replies)
async def test_notification_failure_retry_and_consent_cancellation(released, monkeypatch):
class Failing:
async def send(self, *args, **kwargs):
raise OSError("sensitive error content must not be stored")
monkeypatch.setattr(integrations, "notifier", Failing())
await press(released, "intake:start")
await press(released, "intake:help")
(notice,) = await rows(QuestionnaireNotice)
assert notice.status == "pending" and notice.error_code == "OSError" and notice.attempts == 1
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(minutes=3))
notifier = MemoryNotifier()
monkeypatch.setattr(integrations, "notifier", notifier)
await service.dispatch_notices()
await service.dispatch_notices()
assert len(notifier.sent) == 1
assert (await rows(QuestionnaireNotice))[0].attempts == 2
await press(released, "consent:revoke")
async with session.get_clinical_session() as s:
state = await repo.get_dialog_state(s, TOKEN)
assert state.data["consent"] is False and "questionnaire_record" not in state.data
async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
class Failing:
async def push(self, *args):
raise OSError()
monkeypatch.setattr(integrations, "outbox", Failing())
await jobs.run_until_now()
tasks = await rows(QuestionnaireTask)
assert len(tasks) == 2
risk = next(t for t in tasks if t.kind == "cssrs_since_contact")
assert risk.delivery and risk.sent_at is None
outbox = MemoryOutbox()
monkeypatch.setattr(integrations, "outbox", outbox)
await jobs.run_until_now()
await jobs.run_until_now()
assert len(await rows(QuestionnaireTask)) == 2
assert len(outbox.drain(TOKEN)) == 1
assert len(await rows()) == 2
async def test_missing_since_contact_is_manual_review_not_recent(released):
async with session.get_clinical_session() as s:
task = await service.queue_task(s, TOKEN, "cssrs_since_contact", "synthetic", NOW)
task_id = task.id
replies = await released.handle(
TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id})
)
(row,) = await rows()
assert row.kind == "cssrs_since_contact" and row.status == "review_required" and not row.answers
assert row.details["stop_reason"] == "cssrs_since_contact_required"
assert not answer_button(replies)
async def test_disabled_or_no_adult_confirmation_cannot_start(released):
async with session.get_clinical_session() as s:
state = await repo.get_dialog_state(s, TOKEN)
state.data = {"consent": True}
await press(released, "intake:start")
assert await rows() == []
async def review_risk_record(e):
from app.api.questionnaire import ReviewIn, review
risk = next(r for r in reversed(await rows()) if r.kind == "cssrs_since_contact")
return await review(
TOKEN,
risk.id,
ReviewIn(
doctor_id="synthetic-doctor", assessment_performed=True, decision="resume", reference="synthetic/assessment"
),
)
async def test_doctor_review_is_audited_and_unblocks_diary(released):
from fastapi import HTTPException
from app.api.questionnaire import ReviewIn, history, review
from app.db.audit import AuditLog
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
await jobs.run_until_now()
risk = next(r for r in await rows() if r.kind == "cssrs_since_contact")
with pytest.raises(HTTPException) as exc:
await review(
TOKEN,
risk.id,
ReviewIn(doctor_id="other-doctor", assessment_performed=True, decision="resume", reference="test"),
)
assert exc.value.status_code == 404
await released.handle(TOKEN, Event(type="system", system="doctor_ack"))
async with session.get_clinical_session() as s:
assert (await repo.get_dialog_state(s, TOKEN)).data.get("intake_review_required")
await review_risk_record(released)
await jobs.run_until_now()
assert any(r.kind == "diary" and r.status == "active" for r in await rows())
await history(TOKEN, "synthetic-doctor")
audits = await rows(AuditLog)
assert {a.action for a in audits} >= {"view_questionnaires", "review_questionnaire"}
async def test_diary_numeric_skip_and_not_prescribed(released):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
await press(released, "menu:checkin")
await review_risk_record(released)
replies = await press(released, "menu:checkin")
for _ in range(30):
q = next(
(b.data.split(":")[2] for r in replies for line in r.buttons for b in line if b.data.startswith("iq:")),
None,
)
if q == "comment":
button = next(b.data for r in replies for line in r.buttons for b in line if b.data.endswith(":skip"))
replies = await press(released, button)
elif q:
replies = await press(released, answer_button(replies, {"medication": 3}))
elif any("часов" in r.text for r in replies):
invalid = await released.handle(TOKEN, Event(type="text", text="nan"))
assert any("число часов" in r.text for r in invalid)
replies = await released.handle(TOKEN, Event(type="text", text="6,5"))
else:
break
diary = next(r for r in await rows() if r.kind == "diary")
assert diary.status == "complete" and diary.answers["sleep_hours"] == 6.5
assert "medication_reason" not in diary.answers and "side_effects" not in diary.answers
assert diary.answers["comment"] is None
async def test_high_risk_diary_asks_safety_first_and_stops(released):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
async with session.get_clinical_session() as s:
(await repo.get_profile(s, TOKEN)).enhanced_monitoring = True
await press(released, "menu:checkin")
await review_risk_record(released)
replies = await press(released, "menu:checkin")
button = answer_button(replies)
assert "death_thoughts" in button
replies = await press(released, button)
assert any(r.kind == "crisis" for r in replies)
assert any(b.data == "safety_plan" for r in replies for line in r.buttons for b in line)
diary = next(r for r in await rows() if r.kind == "diary")
assert diary.answers == {"death_thoughts": 1} and diary.status == "interrupted"
async def test_unknown_sex_goes_to_review_without_a_zone(released):
async with session.get_clinical_session() as s:
(await repo.get_profile(s, TOKEN)).sex = None
await complete_intake(released, {"trauma": 1, "sex": 2})
(row,) = await rows()
assert row.status == "review_required" and row.answers["sex"] == "unknown"
async def test_version_change_blocks_resume_and_preserves_answers(released):
replies = await press(released, "intake:start")
await press(released, answer_button(replies))
await press(released, "intake:pause")
path = get_settings().config_dir / "instruments/intake_forms.yaml"
data = yaml.safe_load(path.read_text())
data["version"] = "synthetic-next-version"
path.write_text(yaml.safe_dump(data, allow_unicode=True))
await press(released, "intake:resume")
(row,) = await rows()
assert row.status == "review_required" and row.answers == {"cssrs_q1": 0}
async def test_followup_delta_uses_comparable_complete_baseline(released):
await complete_intake(released, {"trauma": 1, "phq9_1": 2, "tbi": 1, "psychotropics": 1})
async with session.get_clinical_session() as s:
task = await service.queue_task(s, TOKEN, "phq9", "synthetic-repeat", NOW)
task_id = task.id
replies = await released.handle(
TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id})
)
replies = await complete_existing(released, replies, {"phq9_1": 3, "phq9_2": 3, "phq9_3": 1})
repeat = next(r for r in await rows() if r.kind == "phq9")
assert repeat.status == "complete" and repeat.details["delta"]["value"] == 5
assert repeat.details["delta"]["retriage"] is True
async def test_silence_uses_completed_checkins_not_empty_records(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
async with session.get_clinical_session() as s:
(await repo.get_profile(s, TOKEN)).enhanced_monitoring = True
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2))
await jobs.plan()
await jobs.plan()
notices = await rows(QuestionnaireNotice)
assert len([n for n in notices if n.event_key.startswith("silence:")]) == 1
async def test_stop_any_policy_interrupts_first_positive(released):
path = get_settings().config_dir / "questionnaire.yaml"
data = yaml.safe_load(path.read_text())
data["cssrs_positive_policy"] = "stop_any"
path.write_text(yaml.safe_dump(data, allow_unicode=True))
replies = await press(released, "intake:start")
replies = await press(released, answer_button(replies, {"cssrs_q1": 0}))
assert any(r.kind == "crisis" for r in replies)
assert (await rows())[0].answers == {"cssrs_q1": 1}
async def test_evening_schedule_uses_moscow_date(released, monkeypatch):
await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1})
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=15, minute=59))
await jobs.plan()
assert await rows(QuestionnaireTask) == []
monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=16, minute=0))
await jobs.plan()
assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary", "cssrs_since_contact"}
async def test_resumed_answer_keeps_original_period(released, monkeypatch):
replies = await press(released, "intake:start")
await press(released, answer_button(replies))
await press(released, "intake:pause")
monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=1))
replies = await press(released, "intake:resume")
await press(released, answer_button(replies))
(record,) = await rows()
metadata = record.details["answer_metadata"]["cssrs_q2"]
assert metadata["period_start"] == "2026-09-03"
assert metadata["period_end"] == "2026-10-03"
assert metadata["answered_at"].startswith("2026-10-04")
+110
View File
@@ -0,0 +1,110 @@
"""Synthetic exhaustive scoring and calendar/diary boundary checks."""
from datetime import date, timedelta
from itertools import product
import pytest
from app.core.intake_runtime import diary_flags, due_scales, load_runtime, month_before
from app.core.questionnaire import IntakePolicy, alcohol_expansion, load_policy, score_alcohol
from app.core.scales import score
def test_release_is_closed():
assert not load_runtime().released
assert load_policy().approval is None
def test_all_tsq_patterns():
for answers in product((0, 1), repeat=10):
result = score("tsq", {f"tsq_{i + 1}": v for i, v in enumerate(answers)})
assert result.complete and result.total == sum(answers)
assert (result.severity_code == "positive") == (sum(answers) >= 6)
@pytest.mark.parametrize(
"day,expected",
[
(date(2024, 3, 31), date(2024, 2, 29)),
(date(2025, 3, 31), date(2025, 2, 28)),
(date(2026, 1, 1), date(2025, 12, 1)),
],
)
def test_calendar_month(day, expected):
assert month_before(day) == expected
def test_medication_schedule_overlapping_and_delayed_completion():
start = date(2026, 1, 1)
last = {}
for day in (6, 7, 13, 14, 27, 28, 55, 56):
due = due_scales(
today=start + timedelta(days=day), enrolled=start, last=last, route="self_help", medication_changed=start
)
assert ("phq9" in due) == (day in (7, 14, 28, 56))
if "phq9" in due:
last.update(phq9=start + timedelta(days=day), pcl5=start + timedelta(days=day))
# A missed tick yields one form, not each historical milestone.
assert (
due_scales(
today=start + timedelta(days=80), enrolled=start, last={}, route="self_help", medication_changed=start
).count("phq9")
== 1
)
assert "phq9" in due_scales(
today=start + timedelta(days=7), enrolled=start, last={}, route="specialist", after_crisis=start
)
def test_diary_consecutive_days_and_baseline_do_not_include_bad_window():
today = date(2026, 10, 3)
history = {today - timedelta(days=i): {"mood": 8} for i in range(3, 10)}
for i in range(3):
history[today - timedelta(days=i)] = {"mood": 5, "sleep_hours": 3.9, "medication": "none"}
assert set(diary_flags(history, today)) == {"phq9_due_mood", "sleep_review", "medication_contact"}
history[today - timedelta(days=1)] = {}
assert diary_flags(history, today) == ()
history[today]["death_thoughts"] = 1
assert diary_flags(history, today) == ("suicide_assessment_now",)
def test_sleep_alternation_counts_but_threshold_is_strict():
today = date(2026, 10, 3)
h = {
today: {"nightmares": 3},
today - timedelta(days=1): {"sleep_hours": 3.5},
today - timedelta(days=2): {"sleep_hours": 3.5},
}
assert "sleep_review" in diary_flags(h, today)
h[today - timedelta(days=1)]["sleep_hours"] = 4
assert "sleep_review" not in diary_flags(h, today)
def test_total_threshold_and_zone_are_separate_decisions():
data = load_policy().model_dump()
data.update(cssrs_positive_policy="graded", rus_audit_s_expand_at=1)
data["approval"] = dict(
reviewer="Synthetic fixture",
decision_reference="test",
approved_at="2026-10-03T00:00:00Z",
policy_version=data["version"],
)
policy = IntakePolicy.model_validate(data)
r = score_alcohol({"rus_audit_3": 1, "rus_audit_9": 0, "rus_audit_10": 0}, short=True, sex="m")
assert r.zone == 1 and alcohol_expansion(r, policy)
with pytest.raises(ValueError):
IntakePolicy.model_validate({**data, "rus_audit_s_expand_from_zone": 2})
def test_module_rotation_never_drops_suicide_question():
from app.core.intake_questions import diary_questions
first = {
q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=0, day_index=100)
}
second = {
q.id for q in diary_questions(["veteran", "depression"], suicide_monitoring=False, weekday=1, day_index=101)
}
assert "death_thoughts" in first & second
assert "nightmares" in first and "nightmares" not in second
assert "burden" in first and "burden" not in second
+275
View File
@@ -0,0 +1,275 @@
"""Synthetic boundary, branching and safety regression cases for intake (#52)."""
from itertools import product
import pytest
from app.core.questionnaire import (
IntakePolicy,
alcohol_expansion,
evaluate_intake,
extend_alcohol,
load_policy,
score_alcohol,
score_cssrs_recent,
)
from app.core.scales import load_scale
def approved(mode="graded"):
data = load_policy().model_dump()
data.update(cssrs_positive_policy=mode, rus_audit_s_expand_from_zone=2)
data["approval"] = {
"reviewer": "SYNTHETIC TEST ONLY",
"decision_reference": "test-fixture-not-clinical-approval",
"approved_at": "2026-10-03T12:00:00+03:00",
"policy_version": data["version"],
}
return IntakePolicy.model_validate(data)
def zeros(scale):
return {key: 0 for key in load_scale(scale).item_ids}
def intake(**changes):
args = dict(
phq9={"phq9_1": 0, "phq9_2": 0},
tsq={},
pcl5={},
alcohol=zeros("rus_audit_s"),
cssrs={"q1": 0, "q2": 0, "q6_lifetime": 0},
sex="f",
trauma_exposure=False,
policy=approved(),
)
args.update(changes)
return evaluate_intake(**args)
@pytest.mark.parametrize("sex", ["f", "m"])
def test_every_short_alcohol_answer_combination(sex):
# Exhaustively cover all valid patterns, including sparse 0/2/4 history options.
for q3, q9, q10 in product(range(5), (0, 2, 4), (0, 2, 4)):
total = q3 + q9 + q10
expected = (
(1 if total < 2 else 2 if total < 5 else 3 if total == 5 else 4)
if sex == "f"
else (1 if total < 4 else 2 if total < 7 else 3 if total == 7 else 4)
)
result = score_alcohol(
dict(zip(load_scale("rus_audit_s").item_ids, (q3, q9, q10), strict=True)), short=True, sex=sex
)
assert result.complete and result.total == total and result.zone == expected
@pytest.mark.parametrize(
"sex,bounds",
[
("f", [(0, 1), (4, 1), (5, 2), (9, 2), (10, 3), (11, 4), (40, 4)]),
("m", [(0, 1), (8, 1), (9, 2), (13, 2), (14, 3), (16, 3), (17, 4), (40, 4)]),
],
)
def test_full_alcohol_boundaries(sex, bounds):
for total, expected in bounds:
values = zeros("rus_audit")
remaining = total
for item in load_scale("rus_audit").items:
value = max(o.value for o in item.options if o.value <= remaining)
values[item.id] = value
remaining -= value
assert remaining == 0
assert score_alcohol(values, short=False, sex=sex).zone == expected
def test_alcohol_missing_unknown_sex_and_no_implicit_approval():
r = score_alcohol({"rus_audit_3": 0}, short=True, sex="f")
assert not r.complete and r.total is None and r.zone is None
r = score_alcohol(zeros("rus_audit_s"), short=True, sex=None)
assert r.complete and r.total == 0 and r.zone is None
r = score_alcohol({"rus_audit_3": 4, "rus_audit_9": 4, "rus_audit_10": 4}, short=True, sex="m")
assert alcohol_expansion(r, load_policy()) is None
assert alcohol_expansion(r, approved()) is True
def test_full_extension_preserves_history_and_never_counts_twice():
short = {"rus_audit_3": 0, "rus_audit_9": 2, "rus_audit_10": 4}
extra = {"rus_audit_1": 0, **{f"rus_audit_{i}": 0 for i in range(4, 9)}, "rus_audit_9": 2}
merged = extend_alcohol(short, extra)
result = score_alcohol(merged, short=False, sex="f")
assert result.complete and result.total == 6
assert result.derived == ("rus_audit_2",)
assert "rus_audit_2" not in merged # caller-owned raw answers are not mutated
with pytest.raises(ValueError, match="conflicting"):
extend_alcohol(short, {"rus_audit_9": 0})
with pytest.raises(ValueError, match="conflicts"):
score_alcohol({"rus_audit_1": 0, "rus_audit_3": 1}, short=False, sex="m")
@pytest.mark.parametrize("bad", [True, "1", 1.0, -1, 5])
def test_alcohol_rejects_invalid_answers(bad):
with pytest.raises(ValueError):
score_alcohol({"rus_audit_3": bad}, short=True, sex="f")
def test_cannot_use_auditc_items_or_sparse_history_values():
for values in [{"rus_audit_1": 1}, {"auditc_1": 1}, {"rus_audit_9": 1}]:
with pytest.raises(ValueError):
score_alcohol(values, short=True, sex="f")
def test_cssrs_negative_branch_is_complete_without_inventing_zeroes():
a = {"q1": 0, "q2": 0, "q6_lifetime": 0}
r = score_cssrs_recent(a)
assert r.complete and r.risk == "none"
assert r.required == ("q1", "q2", "q6_lifetime")
assert set(a) == set(r.required)
def test_cssrs_remote_behavior_is_not_negative_and_missing_recency_not_low():
a = {"q1": 0, "q2": 0, "q6_lifetime": 1}
r = score_cssrs_recent(a)
assert not r.complete and r.risk is None and r.risk_lower_bound == "moderate"
assert r.missing == ("q6_recent",)
assert score_cssrs_recent({**a, "q6_recent": 0}).risk == "moderate"
assert score_cssrs_recent({**a, "q6_recent": 1}).risk == "high"
def test_cssrs_ideation_branch_and_partial_high():
assert score_cssrs_recent({}).risk is None
assert score_cssrs_recent({"q2": 1}).missing == ("q1", "q3", "q4", "q5", "q6_lifetime")
r = score_cssrs_recent({"q4": 1})
assert not r.complete and r.risk is None and r.risk_lower_bound == "high"
def test_cssrs_exhaustive_complete_ideation_patterns():
for q1, q3, q4, q5, lifetime, recent in product((0, 1), repeat=6):
a = dict(q1=q1, q2=1, q3=q3, q4=q4, q5=q5, q6_lifetime=lifetime)
if lifetime:
a["q6_recent"] = recent
expected = "high" if q4 or q5 or (lifetime and recent) else "moderate" if q3 or lifetime else "low"
r = score_cssrs_recent(a)
assert r.complete and r.risk == expected
def test_cssrs_conflict_preserves_high_signal():
r = score_cssrs_recent({"q1": 0, "q2": 0, "q4": 1, "q6_lifetime": 0, "q6_recent": 1})
assert not r.complete and r.risk_lower_bound == "high" and len(r.conflicts) == 2
@pytest.mark.parametrize("bad", [None, True, "Да", 1.0, 2, -1])
def test_cssrs_requires_explicit_binary_integer(bad):
with pytest.raises(ValueError):
score_cssrs_recent({"q1": bad})
def test_unapproved_policy_never_continues_even_a_negative_screen():
r = intake(policy=load_policy())
assert r.status == "clinical_review_required" and r.primary_route is None
assert intake(policy=load_policy(), cssrs={"q1": 1}).status == "clinical_review_required"
def test_immediate_safety_preempts_unfinished_or_invalid_unrelated_scales():
for overrides in [
dict(help_now=True, cssrs={"broken": "data"}),
dict(phq9={"phq9_9": 1}),
dict(cssrs={"q4": 1}, alcohol={"bad": 50}),
]:
assert intake(policy=load_policy(), **overrides).status == "crisis"
r = intake(phq9={"phq9_9": 1})
assert "phq9_cssrs_discrepancy" in r.flags
def test_physician_must_choose_between_stop_and_graded():
low = {"q1": 1, "q2": 0, "q6_lifetime": 0}
assert intake(cssrs=low, policy=approved("stop_any")).status == "crisis"
r = intake(cssrs=low, policy=approved("graded"))
assert r.status == "ready" and "cssrs_low_safety_plan" in r.flags
data = approved().model_dump()
data["version"] = "changed"
with pytest.raises(ValueError, match="matching version"):
IntakePolicy.model_validate(data)
def test_phq2_and_trauma_expansion_do_not_turn_missing_into_negative():
assert intake().primary_route == "self_help"
r = intake(phq9={"phq9_1": 1, "phq9_2": 1})
assert r.status == "incomplete" and "phq9_9" in r.missing
assert intake(trauma_exposure=None).status == "incomplete"
assert intake(trauma_exposure=True, tsq={"tsq_1": 1}).status == "incomplete"
pc = {f"tsq_{i}": int(i <= 6) for i in range(1, 11)}
assert intake(trauma_exposure=True, tsq=pc).status == "incomplete"
assert intake(trauma_exposure=True, tsq=pc, pcl5=zeros("pcl5")).primary_route == "psychologist"
@pytest.mark.parametrize(
"total,route",
[
(30, "self_help"),
(31, "psychiatrist_or_psychotherapist"),
(49, "psychiatrist_or_psychotherapist"),
(50, "priority_psychiatrist"),
],
)
def test_pdf_pcl_boundaries_are_distinct_from_legacy_33(total, route):
values = zeros("pcl5")
for key in values:
values[key] = min(4, total)
total -= values[key]
assert intake(pcl5=values).primary_route == route
def test_urgent_route_and_parallel_referrals_do_not_overwrite_each_other():
alcohol = {i.id: i.max_value for i in load_scale("rus_audit").items}
r = intake(
cssrs={"q1": 0, "q2": 0, "q6_lifetime": 1, "q6_recent": 0},
alcohol=alcohol,
traumatic_brain_injury=True,
psychotropics=True,
)
assert r.primary_route == "urgent_psychiatrist"
assert r.parallel_routes == ("narcologist", "neurologist")
assert "alcohol_psychotropics" in r.flags
def test_alcohol_expansion_and_unknown_sex_block_routine_route():
assert intake(alcohol={"rus_audit_3": 2, "rus_audit_9": 0, "rus_audit_10": 0}).status == "incomplete"
r = intake(sex=None)
assert r.status == "incomplete" and "alcohol_scoring_sex" in r.missing
def test_non_drinker_does_not_derive_missing_short_answer():
values = zeros("rus_audit")
del values["rus_audit_2"]
del values["rus_audit_3"]
assert intake(alcohol=values).status == "incomplete"
assert "rus_audit_3" in intake(alcohol=values).missing
def test_crisis_keeps_other_flags_and_bad_data_cannot_hide_explicit_signal():
alcohol = {i.id: i.max_value for i in load_scale("rus_audit").items}
r = intake(cssrs={"q4": 1}, pcl5={"pcl5_15": 3}, alcohol=alcohol, traumatic_brain_injury=True, psychotropics=True)
assert r.status == "crisis"
assert {"pcl5_behavior", "alcohol_zone_iv", "alcohol_psychotropics", "traumatic_brain_injury"} <= set(r.flags)
assert intake(cssrs={"q4": 1, "invalid": 7}).status == "crisis"
assert intake(phq9={"phq9_9": 1}, cssrs={"invalid": 7}).status == "crisis"
@pytest.mark.parametrize(
"total,route",
[
(4, "self_help"),
(5, "psychologist"),
(9, "psychologist"),
(10, "psychiatrist_or_psychotherapist"),
(19, "psychiatrist_or_psychotherapist"),
(20, "priority_psychiatrist"),
(24, "priority_psychiatrist"),
],
)
def test_phq_routes_at_each_boundary_without_positive_item9(total, route):
values = zeros("phq9")
for key in list(values)[:8]:
values[key] = min(3, total)
total -= values[key]
assert intake(phq9=values).primary_route == route
+5 -4
View File
@@ -159,9 +159,10 @@ def test_crisis_reply_patient_default() -> None:
assert isinstance(script, CrisisScript)
assert 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"
+1
View File
@@ -30,6 +30,7 @@ def severity(scale_id: str, total: int, **kw: object) -> str:
def test_all_scales_load_with_expected_item_counts() -> None:
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, tsq=10)
assert counts == expected