chore(core): stage 1 contracts — dialog, API schemas + fixtures, integrations, monitoring, modules
CI / ruff · pytest (pull_request) Successful in 22s
CI / ruff · pytest (pull_request) Successful in 22s
Freeze interfaces between lanes A (bot), B (backend), C (dashboard) so three developers work in parallel without touching each other's files: dialog Event/Reply + engine registry with echo stub, integration protocols with in-memory defaults, MonitoringService with noop stub, /api/v1 schemas with fixture-backed routes, frozen main.py wiring, config/modules.yaml + due_scales(), DAR-5, tzdata, Andrey trajectory, lanes in WORKFLOW, ADR-010. Refs #34 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
"""Clinical modules config (`config/modules.yaml`, ADR-001) and scale scheduling.
|
||||
|
||||
Shared by the dialog engine (lane A: start screening, daily question) and the scheduler (lane B: due scales).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
ModuleId = Literal["veteran", "depression", "family"]
|
||||
|
||||
|
||||
class _Strict(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
|
||||
class ScheduleEntry(_Strict):
|
||||
scale: str
|
||||
every_days: int = Field(gt=0)
|
||||
from_day: int = 0
|
||||
until_day: int | None = None
|
||||
|
||||
|
||||
class Threshold(_Strict):
|
||||
scale: str
|
||||
op: Literal[">=", ">"]
|
||||
value: int
|
||||
|
||||
|
||||
class ModuleDef(_Strict):
|
||||
title: str
|
||||
start_scales: list[str]
|
||||
start_questions: list[str] = Field(default_factory=list)
|
||||
daily_question: str | None = None
|
||||
schedule: list[ScheduleEntry] = Field(default_factory=list)
|
||||
enables_depression_if: Threshold | None = None
|
||||
enhanced_monitoring_under_age: int | None = None
|
||||
|
||||
|
||||
class CoreDef(_Strict):
|
||||
daily: list[str]
|
||||
silence_alert_days: int
|
||||
item9_followup: str
|
||||
|
||||
|
||||
class ModulesConfig(_Strict):
|
||||
core: CoreDef
|
||||
modules: dict[ModuleId, ModuleDef]
|
||||
|
||||
|
||||
@lru_cache
|
||||
def load_modules(path: Path | None = None) -> ModulesConfig:
|
||||
path = path or get_settings().config_dir / "modules.yaml"
|
||||
return ModulesConfig.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||
|
||||
|
||||
def due_scales(
|
||||
modules: set[str], therapy_day: int, last_done: dict[str, date], today: date, config: ModulesConfig | None = None
|
||||
) -> list[str]:
|
||||
"""Scales due today for a patient: per enabled module, a scale is due if never done in its window or
|
||||
`every_days` have passed since it was last completed. Deduplicated, in config order."""
|
||||
cfg = config or load_modules()
|
||||
out: list[str] = []
|
||||
for mod_id, mod in cfg.modules.items():
|
||||
if mod_id not in modules:
|
||||
continue
|
||||
for e in mod.schedule:
|
||||
if therapy_day < e.from_day or (e.until_day is not None and therapy_day > e.until_day):
|
||||
continue
|
||||
last = last_done.get(e.scale)
|
||||
if (last is None or (today - last).days >= e.every_days) and e.scale not in out:
|
||||
out.append(e.scale)
|
||||
return out
|
||||
+3
-9
@@ -68,9 +68,7 @@ _PHONE_MOBILE = re.compile(r"(?<![\d\w])\(?9\d{2}\)?[ \-]?\d{3}[ \-]?\d{2}[ \-]?
|
||||
# Local 7-digit number with mandatory dashes (123-45-67); dashes keep "4-6 часов" safe.
|
||||
_PHONE_LOCAL = re.compile(r"(?<![\d\w\-])\d{3}-\d{2}-\d{2}(?![\d\-])")
|
||||
# Passport: "серия 4510 номер 123456" or a bare "45 10 123456" / "4510 123456".
|
||||
_PASSPORT_KW = re.compile(
|
||||
r"серия\s*:?\s*\d{2}\s?\d{2}\s*,?\s*(?:(?:№|номер|n)\s*:?\s*)?\d{6}(?!\d)", re.IGNORECASE
|
||||
)
|
||||
_PASSPORT_KW = re.compile(r"серия\s*:?\s*\d{2}\s?\d{2}\s*,?\s*(?:(?:№|номер|n)\s*:?\s*)?\d{6}(?!\d)", re.IGNORECASE)
|
||||
_PASSPORT_BARE = re.compile(r"(?<!\d)\d{2}\s?\d{2}\s(?:№\s*)?\d{6}(?!\d)")
|
||||
_SNILS_PLAIN = re.compile(r"(?<!\d)\d{11}(?!\d)")
|
||||
_INN = re.compile(r"(?<!\d)(?:\d{12}|\d{10})(?!\d)")
|
||||
@@ -91,9 +89,7 @@ _DATES = [
|
||||
re.compile(r"(?<!\d)(?:19|20)\d{2}\s*(?:г\.\s*р\.?|г\.р\.?|года\s+рождения)", re.IGNORECASE),
|
||||
]
|
||||
# "родился в 1985", "дата рождения: 1985" — replace only the year.
|
||||
_BIRTH_YEAR = re.compile(
|
||||
r"((?:родил(?:ся|ась)|рождения)\s*:?\s*(?:в\s+)?)((?:19|20)\d{2})(?!\d)", re.IGNORECASE
|
||||
)
|
||||
_BIRTH_YEAR = re.compile(r"((?:родил(?:ся|ась)|рождения)\s*:?\s*(?:в\s+)?)((?:19|20)\d{2})(?!\d)", re.IGNORECASE)
|
||||
|
||||
_STREET_TYPE = (
|
||||
r"(?:ул\.|улица|улице|улицу|улицы|пр-т|пр-кт|просп\.|проспект|проспекте|пер\.|переулок|переулке"
|
||||
@@ -266,9 +262,7 @@ def _name_index() -> _NameIndex:
|
||||
_TOKEN = re.compile(r"[А-ЯЁа-яё]+(?:-[А-ЯЁа-яё]+)*")
|
||||
_CAPITALISED = re.compile(r"^[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)*$")
|
||||
# Patronymics: -ович/-евич/-ьич (+ case endings), -овна/-евна/-ична (+ case endings).
|
||||
_PATRONYMIC = re.compile(
|
||||
r"^[а-яё]+(?:(?:(?:ов|ев|ёв)ич|ьич|инич)(?:а|у|ем|е)?|(?:(?:ов|ев|ёв)н|ичн)(?:а|ы|е|у|ой))$"
|
||||
)
|
||||
_PATRONYMIC = re.compile(r"^[а-яё]+(?:(?:(?:ов|ев|ёв)ич|ьич|инич)(?:а|у|ем|е)?|(?:(?:ов|ев|ёв)н|ичн)(?:а|ы|е|у|ой))$")
|
||||
# Strong surname suffixes: -ов/-ев/-ёв, -ский/-цкий, -енко with case endings.
|
||||
_SURNAME_STRONG = re.compile(
|
||||
r"^[а-яё]{2,}(?:(?:ов|ев|ёв)(?:а|у|ым|ой|ою|е|ых)?|(?:ск|цк)(?:ий|ая|ого|ому|им|ой|ую|ом)|енко)$"
|
||||
|
||||
+2
-6
@@ -320,9 +320,7 @@ def _has_affirmed_match(n: _Normalised, rule: _Rule, neg: _NegationCfg) -> bool:
|
||||
# ---------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def check(
|
||||
text: ScrubbedText, llm_signal: RiskLevel | None = None, *, lexicon: Lexicon | None = None
|
||||
) -> SafetyResult:
|
||||
def check(text: ScrubbedText, llm_signal: RiskLevel | None = None, *, lexicon: Lexicon | None = None) -> SafetyResult:
|
||||
"""Classify crisis risk of one message. Deterministic; the LLM signal can only raise the level."""
|
||||
lex = lexicon or _default_lexicon()
|
||||
n = _normalise(text)
|
||||
@@ -405,9 +403,7 @@ def _render(body: str, resources: list[CrisisResource], doctor_notified: str, gr
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def crisis_reply(
|
||||
categories: list[str], audience: Audience = "patient", *, path: Path = CRISIS_PATH
|
||||
) -> CrisisScript:
|
||||
def crisis_reply(categories: list[str], audience: Audience = "patient", *, path: Path = CRISIS_PATH) -> CrisisScript:
|
||||
"""Fixed crisis script for an ACUTE result. Never generated by the LLM."""
|
||||
cfg = _crisis_cfg(path)
|
||||
variant = f"{audience}.default"
|
||||
|
||||
Reference in New Issue
Block a user