"""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), ())