Files
PTSRaiAgent/app/core/questionnaire.py
T
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

335 lines
15 KiB
Python

"""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_from_zone: int | None = Field(default=None, ge=1, le=4, strict=True)
phq2_expand_at: int = Field(ge=1, le=6, strict=True)
pcptsd5_expand_at: int = Field(ge=1, le=5, strict=True)
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
):
raise ValueError("approval requires the matching version and resolved clinical decisions")
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 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
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:
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],
pcptsd5: 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("pcptsd5", pcptsd5)
pcl = score("pcl5", pcl5)
full_alcohol = score_alcohol(alcohol, short=False, sex=sex)
short_values = {k: v for k, v in alcohol.items() if k in load_scale("rus_audit_s").item_ids}
if "rus_audit_3" in full_alcohol.derived:
short_values["rus_audit_3"] = 0
short_alcohol = score_alcohol(short_values, short=True, sex=sex)
missing = list(c.missing)
phq2_items = ("phq9_1", "phq9_2")
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 any(pcptsd5.values()):
flags.append("trauma_filter_conflict")
pc_positive = trauma_exposure is True and pc.complete and pc.total >= policy.pcptsd5_expand_at
if trauma_exposure is True and not pc.complete:
missing.extend(k for k in load_scale("pcptsd5").item_ids if k not in pcptsd5)
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), ())