chore(core): stage 1 contracts — dialog, API schemas + fixtures, integrations, monitoring, modules
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:
2026-10-03 15:22:25 +03:00
co-authored by Claude Opus 5.5
parent a86008f248
commit b4731fb1d6
30 changed files with 1126 additions and 89 deletions
+278
View File
@@ -0,0 +1,278 @@
"""Deterministic fixture data returned by stub routes until #12 implements them (synthetic only).
Lets the dashboard (lane C) be built against the frozen schemas right away. Mirrors the demo cabinet from
docs/product/01_demo_persona.md: veteran Andrey on day 13 (red) plus seven other patients.
"""
from datetime import date, datetime, time, timedelta
from zoneinfo import ZoneInfo
from app.api.schemas import (
AlertOut,
DiscontinuationRisk,
DoseOut,
PatientCard,
PatientListItem,
SeriesPoint,
)
from app.integrations.base import Prescription
TZ = ZoneInfo("Europe/Moscow")
TODAY = date(2026, 10, 3)
DOCTOR_ID = "doc-smirnova"
def _risk(score: int, factors: list[str]) -> DiscontinuationRisk:
level = "high" if score >= 60 else "medium" if score >= 30 else "low"
return DiscontinuationRisk(score=score, level=level, factors=factors)
PATIENTS: list[PatientListItem] = [
PatientListItem(
token="tok-andrey",
display_name="Андрей С.",
age=34,
modules=["veteran", "depression"],
drug="сертралин 50 мг",
therapy_day=13,
next_visit=TODAY + timedelta(days=15),
status_color="red",
top_alert_level="acute",
open_alerts=3,
phq9_last=18,
pcl5_last=44,
trend="up",
adherence_7d=1.0,
discontinuation_risk=_risk(55, ["хотел бросить (день 5)", "алкоголь 3 дня", "настроение ≤ 3"]),
last_event="PHQ-9 п.9 = 2 — кризисный протокол",
),
PatientListItem(
token="tok-dmitry",
display_name="Дмитрий В.",
age=41,
modules=["veteran"],
drug="сертралин 100 мг",
therapy_day=45,
status_color="yellow",
top_alert_level="moderate",
open_alerts=1,
phq9_last=11,
pcl5_last=48,
trend="up",
adherence_7d=0.86,
discontinuation_risk=_risk(25, ["бессонница"]),
last_event="кошмары, сон < 4 ч 3 ночи",
),
PatientListItem(
token="tok-sergey",
display_name="Сергей П.",
age=37,
modules=["veteran"],
drug="пароксетин 20 мг",
therapy_day=30,
status_color="yellow",
top_alert_level="moderate",
open_alerts=1,
phq9_last=12,
pcl5_last=41,
trend="flat",
adherence_7d=0.57,
discontinuation_risk=_risk(70, ["пропуски 3 из 7", "нет ответа 2 дня"]),
last_event="пропуски 3 из 7 дней",
),
PatientListItem(
token="tok-nikolay",
display_name="Николай Ф.",
age=52,
modules=["veteran"],
drug="сертралин 50 мг",
therapy_day=14,
status_color="yellow",
top_alert_level="moderate",
open_alerts=1,
phq9_last=13,
pcl5_last=39,
trend="flat",
adherence_7d=0.86,
discontinuation_risk=_risk(40, ["алкоголь 4 из 7"]),
last_event="алкоголь 4 из 7 дней",
),
PatientListItem(
token="tok-anna",
display_name="Анна Л.",
age=23,
modules=["depression"],
drug="флуоксетин 20 мг",
therapy_day=8,
status_color="yellow",
top_alert_level="info",
open_alerts=1,
phq9_last=15,
trend="flat",
adherence_7d=1.0,
discontinuation_risk=_risk(30, ["беспокойство"]),
last_event="возраст < 25: усиленный мониторинг",
),
PatientListItem(
token="tok-irina",
display_name="Ирина К.",
age=34,
modules=["depression"],
drug="эсциталопрам 10 мг",
therapy_day=21,
status_color="green",
phq9_last=9,
trend="down",
adherence_7d=1.0,
discontinuation_risk=_risk(5, []),
last_event="PHQ-9 16 → 9",
),
PatientListItem(
token="tok-olga",
display_name="Ольга Н.",
age=29,
modules=["depression"],
drug="сертралин 50 мг",
therapy_day=10,
status_color="green",
phq9_last=12,
trend="down",
adherence_7d=1.0,
discontinuation_risk=_risk(10, []),
last_event="тошнота прошла",
),
PatientListItem(
token="tok-pavel",
display_name="Павел Р.",
age=45,
modules=["depression"],
drug="эсциталопрам 10 мг",
therapy_day=60,
status_color="green",
phq9_last=4,
trend="down",
adherence_7d=1.0,
discontinuation_risk=_risk(5, []),
last_event="ремиссия, PHQ-9 = 4",
),
]
# Andrey's 13 days (docs/product/01_demo_persona.md): mood, sleep hours (bucket midpoint), alcohol 0/1/2.
_ANDREY_DAYS = [
(4, 7, 0),
(4, 5, 0),
(4, 5, 0),
(3, 5, 0),
(3, 5, 0),
(4, 7, 0),
(4, 7, 0),
(4, 3, 1),
(4, 3, 2),
(4, 3, 2),
(3, 5, 1),
(3, 5, 0),
(2, 3, 0),
]
def _dt(d: date, hh: int = 9) -> datetime:
return datetime.combine(d, time(hh), TZ)
def andrey_card() -> PatientCard:
start = TODAY - timedelta(days=12)
series: list[SeriesPoint] = []
for i, (mood, sleep, alc) in enumerate(_ANDREY_DAYS):
d = start + timedelta(days=i)
series += [
SeriesPoint(day=d, metric="mood", value=mood),
SeriesPoint(day=d, metric="sleep_hours", value=sleep),
SeriesPoint(day=d, metric="alcohol", value=alc),
]
series += [
SeriesPoint(day=start - timedelta(days=3), metric="phq9", value=14),
SeriesPoint(day=start - timedelta(days=3), metric="pcl5", value=46),
SeriesPoint(day=start - timedelta(days=3), metric="auditc", value=3),
SeriesPoint(day=start + timedelta(days=6), metric="phq9", value=13),
SeriesPoint(day=start + timedelta(days=6), metric="pcl5", value=44),
SeriesPoint(day=TODAY, metric="phq9", value=18, scheduled=False),
]
doses = [
DoseOut(planned_at=_dt(start + timedelta(days=i), 8), drug="сертралин", status="skipped" if i == 4 else "taken")
for i in range(13)
]
alerts = [
AlertOut(
id=3,
token="tok-andrey",
patient_name="Андрей С.",
ts=_dt(TODAY, 21),
level="acute",
color="red",
code="phq9_item9",
reason="PHQ-9 п.9 = 2 (мысли о смерти/самоповреждении)",
),
AlertOut(
id=2,
token="tok-andrey",
patient_name="Андрей С.",
ts=_dt(start + timedelta(days=9), 21),
level="moderate",
color="yellow",
code="alcohol_3d",
reason="Алкоголь 3 дня подряд на фоне СИОЗС",
),
AlertOut(
id=1,
token="tok-andrey",
patient_name="Андрей С.",
ts=_dt(start + timedelta(days=9), 21),
level="moderate",
color="yellow",
code="sleep_lt4_3n",
reason="Сон < 4 ч три ночи подряд",
),
AlertOut(
id=0,
token="tok-andrey",
patient_name="Андрей С.",
ts=_dt(start + timedelta(days=4), 21),
level="high",
color="yellow",
code="quit_intent",
reason="Намерение прекратить приём",
acked_by=DOCTOR_ID,
acked_at=_dt(start + timedelta(days=5), 11),
),
]
return PatientCard(
patient=PATIENTS[0],
prescriptions=[Prescription(drug="сертралин", dose="50 мг", instructions="утром после еды", start_date=start)],
series=series,
doses=doses,
alerts=alerts,
safety_plan=[
"Мои сигналы: не сплю, пью, «всё бессмысленно»",
"Что помогает: прогулка с собакой, душ",
"Люди и места: брат Олег, спортзал",
"Кому звонить: жена, сослуживец Миша",
"Специалисты: д-р Смирнова, 8-800-2000-122, 112",
"Безопасная среда: ружьё хранится у брата",
],
summary="За 13 дней: приверженность 12/13, эпизод намерения бросить на 5-й день (снят после звонка врача). "
"С 8-го дня сон < 4 ч и алкоголь; к 13-му дню настроение 2/10, внеплановый PHQ-9 = 18 (рост с 13), "
"п.9 = 2. Обсудить: безопасность, коррекция терапии сна, алкоголь.",
)
def card(token: str) -> PatientCard | None:
if token == "tok-andrey":
return andrey_card()
item = next((p for p in PATIENTS if p.token == token), None)
if item is None:
return None
return PatientCard(patient=item, prescriptions=[], series=[], doses=[], alerts=[], summary=item.last_event)
def all_alerts() -> list[AlertOut]:
return andrey_card().alerts
+117
View File
@@ -0,0 +1,117 @@
"""API routes `/api/v1` (#34 stubs → #12 implementation).
Stubs return deterministic fixtures (`app/api/fixtures.py`) so the dashboard can be built immediately.
Lane B replaces bodies in #12 without changing paths or schemas (those are the frozen contract).
"""
from fastapi import APIRouter, HTTPException
from app.api import fixtures
from app.api.schemas import (
AckIn,
AlertOut,
ClockAdvanceIn,
ClockOut,
ConnectPatientIn,
ConnectPatientOut,
DemoPersona,
DialogIn,
DialogOut,
MessageOut,
PatientCard,
PatientListItem,
)
from app.core import clock
from app.dialog.contracts import get_engine
from app.integrations.base import MemoryOutbox, integrations
router = APIRouter(prefix="/api/v1")
# ------------------------------------------------------------------ doctor dashboard
@router.get("/doctors/{doctor_id}/patients", response_model=list[PatientListItem], tags=["doctor"])
async def list_patients(doctor_id: str) -> list[PatientListItem]:
"""Doctor's patients sorted red → yellow → grey → green, then by discontinuation risk."""
order = {"red": 0, "yellow": 1, "grey": 2, "green": 3}
return sorted(fixtures.PATIENTS, key=lambda p: (order[p.status_color], -p.discontinuation_risk.score))
@router.get("/patients/{token}", response_model=PatientCard, tags=["doctor"])
async def patient_card(token: str, doctor_id: str) -> PatientCard:
"""Patient card. Every call is written to audit_log as `view_patient` (#12)."""
card = fixtures.card(token)
if card is None:
raise HTTPException(404, "patient not found")
return card
@router.get("/doctors/{doctor_id}/alerts", response_model=list[AlertOut], tags=["doctor"])
async def list_alerts(doctor_id: str, since_id: int = -1, open_only: bool = False) -> list[AlertOut]:
"""Polled every 2–3 s by the dashboard: alerts with id > since_id, newest first."""
alerts = [a for a in fixtures.all_alerts() if a.id > since_id]
return [a for a in alerts if a.acked_at is None] if open_only else alerts
@router.post("/alerts/{alert_id}/ack", response_model=AlertOut, tags=["doctor"])
async def ack_alert(alert_id: int, body: AckIn) -> AlertOut:
"""Doctor acknowledges an alert (written to audit_log). Acking an acute alert lets the patient leave crisis."""
alert = next((a for a in fixtures.all_alerts() if a.id == alert_id), None)
if alert is None:
raise HTTPException(404, "alert not found")
return alert.model_copy(update={"acked_by": body.doctor_id, "acked_at": clock.now()})
@router.post("/patients/connect", response_model=ConnectPatientOut, tags=["doctor"])
async def connect_patient(body: ConnectPatientIn) -> ConnectPatientOut:
"""Doctor connects a patient after the in-person visit (323-FZ art. 36.2): prescription → МИС mock, code/QR."""
return ConnectPatientOut(token="tok-new", code="DEMO-0001", deep_link="https://t.me/ptsr_demo_bot?start=DEMO-0001")
# ------------------------------------------------------------------ patient dialog (web console / any channel)
@router.post("/dialog/{token}/events", response_model=DialogOut, tags=["dialog"])
async def dialog_event(token: str, body: DialogIn) -> DialogOut:
return DialogOut(replies=await get_engine().handle(token, body))
@router.get("/dialog/{token}/outbox", response_model=DialogOut, tags=["dialog"])
async def dialog_outbox(token: str) -> DialogOut:
"""System-initiated replies (reminders, check-ins) waiting for the web console."""
box = integrations.outbox
return DialogOut(replies=box.drain(token) if isinstance(box, MemoryOutbox) else [])
@router.get("/dialog/{token}/history", response_model=list[MessageOut], tags=["dialog"])
async def dialog_history(token: str, limit: int = 100) -> list[MessageOut]:
return []
# ------------------------------------------------------------------ demo controls (ADR-005)
def _clock_out() -> ClockOut:
return ClockOut(now=clock.now(), virtual=clock._virtual(), offset_days=clock.offset().total_seconds() / 86400)
@router.get("/demo/clock", response_model=ClockOut, tags=["demo"])
async def get_clock() -> ClockOut:
return _clock_out()
@router.post("/demo/clock/advance", response_model=ClockOut, tags=["demo"])
async def advance_clock(body: ClockAdvanceIn) -> ClockOut:
"""Moves the virtual clock; #13 makes the scheduler replay all events up to the new time."""
clock.advance(days=body.days, hours=body.hours)
return _clock_out()
@router.post("/demo/reset", tags=["demo"])
async def demo_reset() -> dict[str, str]:
clock.reset()
return {"status": "ok"}
@router.get("/demo/personas", response_model=list[DemoPersona], tags=["demo"])
async def demo_personas() -> list[DemoPersona]:
return [
DemoPersona(
token="tok-andrey", display_name="Андрей С.", therapy_day=13, scenario="data/synthetic/andrey.jsonl"
)
]
+138
View File
@@ -0,0 +1,138 @@
"""API schemas (frozen contract, #34). Dashboard (lane C) is built against these; lane B implements them (#12).
Change only via a separate small contract PR. Colors follow ADR-006: red = acute, yellow = high/moderate,
green = nothing open, grey = info only (e.g. silence).
"""
from datetime import date, datetime
from typing import Literal
from pydantic import BaseModel, Field
from app.dialog.contracts import Event, Reply
from app.integrations.base import Prescription
Color = Literal["red", "yellow", "green", "grey"]
LevelName = Literal["info", "moderate", "high", "acute"]
Module = Literal["veteran", "depression", "family"]
Metric = Literal["phq9", "pcl5", "gad7", "auditc", "dar5", "mood", "sleep_hours", "alcohol"]
class DiscontinuationRisk(BaseModel):
"""Rule-based index (not ML) of the risk to drop therapy: missed doses, side effects, quit intent, low mood."""
score: int = Field(ge=0, le=100)
level: Literal["low", "medium", "high"]
factors: list[str] = Field(default_factory=list) # short Russian labels for the doctor
class PatientListItem(BaseModel):
token: str
display_name: str # "Андрей С." — minimal identification for the treating doctor
age: int
modules: list[Module]
drug: str | None = None # "сертралин 50 мг"
therapy_day: int
next_visit: date | None = None
status_color: Color
top_alert_level: LevelName | None = None
open_alerts: int = 0
phq9_last: int | None = None
pcl5_last: int | None = None
trend: Literal["up", "down", "flat", "unknown"] = "unknown" # direction of symptom scores (up = worse)
adherence_7d: float | None = Field(default=None, ge=0, le=1)
discontinuation_risk: DiscontinuationRisk
last_event: str = ""
class SeriesPoint(BaseModel):
day: date
metric: Metric
value: float
scheduled: bool = True # False for unscheduled scale sessions
class DoseOut(BaseModel):
planned_at: datetime
drug: str
status: Literal["taken", "missed", "skipped", "pending"]
class AlertOut(BaseModel):
id: int
token: str
patient_name: str
ts: datetime
level: LevelName
color: Color
code: str
reason: str
acked_by: str | None = None
acked_at: datetime | None = None
class PatientCard(BaseModel):
patient: PatientListItem
prescriptions: list[Prescription]
series: list[SeriesPoint]
doses: list[DoseOut]
alerts: list[AlertOut]
safety_plan: list[str] | None = None # Stanley–Brown steps, if composed with the doctor
summary: str = "" # LLM summary built from structured data only (#19)
class AckIn(BaseModel):
doctor_id: str
class ConnectPatientIn(BaseModel):
doctor_id: str
display_name: str
birth_year: int
sex: Literal["m", "f"]
snils: str # synthetic only
drug: str
dose: str
times: list[str] = Field(default_factory=lambda: ["08:00"])
instructions: str = ""
next_visit: date | None = None
modules: list[Module] = Field(default_factory=lambda: ["veteran"])
class ConnectPatientOut(BaseModel):
token: str
code: str # one-time connect code (esia_mock)
deep_link: str # t.me/<bot>?start=<code> or web-console link; dashboard renders it as QR
class DialogIn(Event):
"""Same as dialog Event; kept as a separate name for OpenAPI clarity."""
class DialogOut(BaseModel):
replies: list[Reply]
class MessageOut(BaseModel):
ts: datetime
direction: Literal["in", "out"]
text: str # scrubbed for "in", as sent for "out"
kind: str = "normal"
class ClockOut(BaseModel):
now: datetime
virtual: bool
offset_days: float
class ClockAdvanceIn(BaseModel):
days: float = 0
hours: float = 0
class DemoPersona(BaseModel):
token: str
display_name: str
therapy_day: int
scenario: str | None = None # path of data/synthetic/<persona>.jsonl if scripted
+9
View File
@@ -0,0 +1,9 @@
"""Telegram channel — stub until #11. Prototype on synthetic data only; prod channel is MAX / Gosuslugi."""
async def start() -> None:
"""Start polling if TG_BOT_TOKEN is set (#11); no-op otherwise."""
async def stop() -> None:
"""Stop polling (#11)."""
+79
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+5 -15
View File
@@ -82,9 +82,7 @@ async def patients_of_doctor(session: AsyncSession, doctor_id: str) -> Sequence[
# --- daily check-ins and scales ---------------------------------------------------------------------
async def upsert_checkin(
session: AsyncSession, *, token: str, day: date, now: datetime, **fields: Any
) -> DailyCheckin:
async def upsert_checkin(session: AsyncSession, *, token: str, day: date, now: datetime, **fields: Any) -> DailyCheckin:
"""Merge answers into the (token, day) check-in.
The daily dialog fills the row step by step (mood, then medication, then the module question), so
@@ -93,9 +91,7 @@ async def upsert_checkin(
unknown = set(fields) - _CHECKIN_FIELDS
if unknown:
raise ValueError(f"unknown check-in fields: {sorted(unknown)}")
row = await session.scalar(
select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == day)
)
row = await session.scalar(select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == day))
if row is None:
row = DailyCheckin(token=token, day=day, side_effects=[], updated_at=now)
session.add(row)
@@ -107,9 +103,7 @@ async def upsert_checkin(
return row
async def list_checkins(
session: AsyncSession, token: str, since: date | None = None
) -> Sequence[DailyCheckin]:
async def list_checkins(session: AsyncSession, token: str, since: date | None = None) -> Sequence[DailyCheckin]:
stmt = select(DailyCheckin).where(DailyCheckin.token == token)
if since is not None:
stmt = stmt.where(DailyCheckin.day >= since)
@@ -176,9 +170,7 @@ async def add_scale_result(
return row
async def list_scale_results(
session: AsyncSession, token: str, scale: str | None = None
) -> Sequence[ScaleResult]:
async def list_scale_results(session: AsyncSession, token: str, scale: str | None = None) -> Sequence[ScaleResult]:
stmt = select(ScaleResult).where(ScaleResult.token == token)
if scale is not None:
stmt = stmt.where(ScaleResult.scale == scale)
@@ -210,9 +202,7 @@ async def open_alert_codes(session: AsyncSession, token: str) -> set[str]:
return set(rows.all())
async def list_alerts(
session: AsyncSession, *, token: str | None = None, open_only: bool = False
) -> Sequence[Alert]:
async def list_alerts(session: AsyncSession, *, token: str | None = None, open_only: bool = False) -> Sequence[Alert]:
stmt = select(Alert)
if token is not None:
stmt = stmt.where(Alert.token == token)
+74
View File
@@ -0,0 +1,74 @@
"""Dialog contracts (frozen, #34). Channel-agnostic: Telegram and the web demo console render the same replies.
Lane A implements `DialogEngine` (#10); lane B calls it from the API (#12) and the scheduler (#13).
Change only via a separate small contract PR.
"""
from collections.abc import Callable
from importlib import import_module, util
from typing import Literal, Protocol
from pydantic import BaseModel, Field
SystemEventKind = Literal[
"med_reminder", # dose is due: params {"dose_id", "drug", "dose"}
"daily_checkin", # time for the daily check-in
"scale_due", # scheduled / unscheduled scale session: params {"scale", "scheduled"}
"reach_out", # silence or missed doses — caring contact
"doctor_ack", # doctor acknowledged a crisis alert: lets the FSM leave `crisis`
]
class Button(BaseModel):
"""Abstract button; channels render it (inline keyboard / web button). `data` is echoed back on press."""
label: str
data: str
class Event(BaseModel):
"""Incoming event for a patient. Exactly one of text / button / system is meaningful per `type`."""
type: Literal["text", "button", "system", "start"]
text: str | None = None # raw patient text — the engine scrubs it before anything else
button: str | None = None # Button.data of the pressed button
system: SystemEventKind | None = None
params: dict[str, object] = Field(default_factory=dict) # e.g. {"code": "..."} for type="start"
class Reply(BaseModel):
"""Outgoing message. `kind` lets channels style crisis/safety messages differently."""
text: str
buttons: list[list[Button]] = Field(default_factory=list) # rows of buttons
kind: Literal["normal", "question", "crisis", "safety_plan", "info"] = "normal"
class DialogEngine(Protocol):
async def handle(self, token: str, event: Event) -> list[Reply]: ...
class EchoEngine:
"""Placeholder until #10 lands: lets the API and the demo console be built and tested now."""
async def handle(self, token: str, event: Event) -> list[Reply]:
if event.type == "system":
return [Reply(text=f"[stub] system event: {event.system}", kind="info")]
return [Reply(text="[stub] движок диалога появится в #10", kind="info")]
_factory: Callable[[], DialogEngine] = EchoEngine
def register_engine(factory: Callable[[], DialogEngine]) -> None:
"""Lane A calls this at import time of `app.dialog.engine` to replace the echo stub."""
global _factory
_factory = factory
def get_engine() -> DialogEngine:
# Importing the engine module registers the real factory; absent module → echo stub.
# find_spec instead of try/except ImportError: real import errors inside the module must surface.
if util.find_spec("app.dialog.engine") is not None:
import_module("app.dialog.engine")
return _factory()
+114
View File
@@ -0,0 +1,114 @@
"""Integration protocols (frozen, #34). Demo uses mocks; prod swaps implementations (SPEC §9).
Lane B implements the real mocks in `app/integrations/*` (#14) and registers them via `set_*`.
The in-memory defaults below keep every lane unblocked and testable before #14 lands.
"""
from collections import defaultdict
from datetime import date
from typing import Literal, Protocol
from pydantic import BaseModel, Field
from app.dialog.contracts import Reply
AlertLevelName = Literal["info", "moderate", "high", "acute"]
class Prescription(BaseModel):
drug: str # INN, e.g. "сертралин"
dose: str # "50 мг"
schedule: dict[str, list[str]] = Field(default_factory=lambda: {"times": ["08:00"]})
instructions: str = "" # "утром после еды"
start_date: date | None = None
end_date: date | None = None
class VerifiedPerson(BaseModel):
token: str
doctor_id: str
class DrugSection(BaseModel):
inn: str
section: str # side_effects | onset | alcohol | discontinuation | ...
text: str
source: str # reference to the ГРЛС instruction
class PrescriptionSource(Protocol): # МИС / ЕГИСЗ
async def get_prescriptions(self, token: str) -> list[Prescription]: ...
class IdentityProvider(Protocol): # ЕСИА; demo: connect code from the doctor
async def verify(self, auth_code: str) -> VerifiedPerson | None: ...
class Notifier(Protocol): # doctor notifications: dashboard feed + Telegram chat / MAX / МИС
async def send(self, doctor_id: str, level: AlertLevelName, text: str, token: str | None = None) -> None: ...
class DrugInfo(Protocol): # ГРЛС
async def sections(self, inn: str) -> list[DrugSection]: ...
class Outbox(Protocol):
"""Delivers engine replies initiated by the system (reminders, check-ins) to the patient's channel."""
async def push(self, token: str, replies: list[Reply]) -> None: ...
# ----------------------------------------------------------------- in-memory defaults
class MemoryOutbox:
"""Default outbox: per-token queue polled by the web demo console (`drain`)."""
def __init__(self) -> None:
self._q: dict[str, list[Reply]] = defaultdict(list)
async def push(self, token: str, replies: list[Reply]) -> None:
self._q[token].extend(replies)
def drain(self, token: str) -> list[Reply]:
out, self._q[token] = self._q[token], []
return out
class MemoryNotifier:
"""Default notifier: keeps notifications in memory (no PII in `text` — reason codes only)."""
def __init__(self) -> None:
self.sent: list[tuple[str, str, str, str | None]] = []
async def send(self, doctor_id: str, level: AlertLevelName, text: str, token: str | None = None) -> None:
self.sent.append((doctor_id, level, text, token))
class EmptyPrescriptions:
async def get_prescriptions(self, token: str) -> list[Prescription]:
return []
class NoDrugInfo:
async def sections(self, inn: str) -> list[DrugSection]:
return []
class RejectIdentity:
async def verify(self, auth_code: str) -> VerifiedPerson | None:
return None
class Integrations:
"""Registry. Lane B replaces fields at startup (`app.integrations.setup()`, #14)."""
def __init__(self) -> None:
self.outbox: Outbox = MemoryOutbox()
self.notifier: Notifier = MemoryNotifier()
self.prescriptions: PrescriptionSource = EmptyPrescriptions()
self.drug_info: DrugInfo = NoDrugInfo()
self.identity: IdentityProvider = RejectIdentity()
integrations = Integrations()
+1 -3
View File
@@ -168,9 +168,7 @@ def get_gateway() -> LLMGateway:
# Module-level shortcuts, so callers can `from app.llm import gateway; await gateway.extract_item(...)`.
async def extract_item(
scale_item_id: str, item_text: str, max_score: int, answer: ScrubbedText
) -> ItemScore | None:
async def extract_item(scale_item_id: str, item_text: str, max_score: int, answer: ScrubbedText) -> ItemScore | None:
return await get_gateway().extract_item(scale_item_id, item_text, max_score, answer)
+1 -3
View File
@@ -107,9 +107,7 @@ def _extract_item(scale_item_id: str, max_score: int, answer: str) -> ItemScore:
for fraction, cues in _SEVERITY_CUES:
hit = _any(cues, text)
if hit:
return ItemScore(
item=scale_item_id, score=round(fraction * max_score), confidence=0.8, evidence=hit
)
return ItemScore(item=scale_item_id, score=round(fraction * max_score), confidence=0.8, evidence=hit)
# No frequency/intensity cue -> low confidence, the gateway turns it into a clarifying question.
return ItemScore(item=scale_item_id, score=0, confidence=0.3, evidence="")
+10 -2
View File
@@ -1,4 +1,4 @@
"""Application entrypoint: FastAPI API (+ scheduler and Telegram bot once implemented)."""
"""Application entrypoint: FastAPI API + scheduler + Telegram bot (frozen wiring, #34)."""
import logging
from collections.abc import AsyncGenerator
@@ -7,9 +7,12 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from sqlalchemy.exc import SQLAlchemyError
from app.api.routes import router
from app.channels import telegram
from app.config import get_settings
from app.db.bootstrap import init_db
from app.db.session import dispose
from app.scheduler import jobs as scheduler
# Logs carry only tokens, states and event codes — never PII or raw message text.
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
@@ -27,12 +30,17 @@ async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
# In compose Postgres is required and healthy before the app starts; this branch is for running the
# API (and its tests) without a database. Endpoints touching the DB will fail until it is reachable.
log.error("db init failed, continuing without db: %s", type(exc).__name__)
# Scheduler (#13) and Telegram bot (#11) hook in here.
# Lanes fill these in (#13, #11); main.py itself stays frozen (#34).
await scheduler.start()
await telegram.start()
yield
await telegram.stop()
await scheduler.stop()
await dispose()
app = FastAPI(title="PTSRaiAgent API", version="0.1.0", lifespan=lifespan)
app.include_router(router)
@app.get("/health")
+13
View File
@@ -0,0 +1,13 @@
"""Scheduler on the (virtual) demo clock — stub until #13. Called from app.main lifespan."""
async def start() -> None:
"""Start periodic ticking (#13)."""
async def stop() -> None:
"""Stop the scheduler (#13)."""
async def run_until_now() -> None:
"""Replay all due events up to `app.core.clock.now()` in order, idempotently (#13)."""
View File
+47
View File
@@ -0,0 +1,47 @@
"""Monitoring service contract (frozen, #34).
Split of responsibilities (ADR-007):
* Lane B (#38) implements `MonitoringService`: builds a patient snapshot from the DB, runs the deterministic
rules (`app.core.rules.evaluate`), persists `alert`/`crisis` actions and notifies the doctor itself.
* It returns only the *dialog* actions (schedule_scale, med_info, reach_out, show_safety_plan, suggest_visit,
crisis) so the engine (lane A, #10) can react in the conversation. The engine never creates alerts.
"""
from collections.abc import Callable
from importlib import import_module, util
from typing import Protocol
from app.core.rules import Action
class MonitoringService(Protocol):
async def after_data_change(self, token: str) -> list[Action]: ...
async def is_in_crisis(self, token: str) -> bool:
"""True while an `acute` alert is open (not acked by the doctor) — FSM stays in `crisis`."""
...
class NoopMonitoring:
"""Placeholder until #38 lands."""
async def after_data_change(self, token: str) -> list[Action]:
return []
async def is_in_crisis(self, token: str) -> bool:
return False
_factory: Callable[[], MonitoringService] = NoopMonitoring
def register_monitoring(factory: Callable[[], MonitoringService]) -> None:
global _factory
_factory = factory
def get_monitoring() -> MonitoringService:
# find_spec instead of try/except ImportError: real import errors inside the module must surface.
if util.find_spec("app.services.monitoring") is not None:
import_module("app.services.monitoring")
return _factory()