From b4731fb1d67c756d2f4ef59f84e562fc6bddef5a Mon Sep 17 00:00:00 2001 From: Lacky1234union Date: Sat, 3 Oct 2026 15:22:25 +0300 Subject: [PATCH] =?UTF-8?q?chore(core):=20stage=201=20contracts=20?= =?UTF-8?q?=E2=80=94=20dialog,=20API=20schemas=20+=20fixtures,=20integrati?= =?UTF-8?q?ons,=20monitoring,=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 4 +- app/api/fixtures.py | 278 ++++++++++++++++++++++++++++++++ app/api/routes.py | 117 ++++++++++++++ app/api/schemas.py | 138 ++++++++++++++++ app/channels/telegram.py | 9 ++ app/core/modules.py | 79 +++++++++ app/core/privacy.py | 12 +- app/core/safety.py | 8 +- app/db/repo.py | 20 +-- app/dialog/contracts.py | 74 +++++++++ app/integrations/base.py | 114 +++++++++++++ app/llm/gateway.py | 4 +- app/llm/mock.py | 4 +- app/main.py | 12 +- app/scheduler/jobs.py | 13 ++ app/services/__init__.py | 0 app/services/contracts.py | 47 ++++++ config/modules.yaml | 42 +++++ config/scales/dar5.yaml | 26 +++ docs/DECISIONS.md | 6 + docs/dev/WORKFLOW.md | 47 ++++-- docs/product/01_demo_persona.md | 60 ++++++- pyproject.toml | 3 +- tests/test_contracts.py | 21 +++ tests/test_db.py | 16 +- tests/test_modules.py | 33 ++++ tests/test_privacy.py | 4 +- tests/test_rules.py | 20 +-- tests/test_scoring.py | 2 +- uv.lock | 2 + 30 files changed, 1126 insertions(+), 89 deletions(-) create mode 100644 app/api/fixtures.py create mode 100644 app/api/routes.py create mode 100644 app/api/schemas.py create mode 100644 app/channels/telegram.py create mode 100644 app/core/modules.py create mode 100644 app/dialog/contracts.py create mode 100644 app/integrations/base.py create mode 100644 app/scheduler/jobs.py create mode 100644 app/services/__init__.py create mode 100644 app/services/contracts.py create mode 100644 config/modules.yaml create mode 100644 config/scales/dar5.yaml create mode 100644 tests/test_contracts.py create mode 100644 tests/test_modules.py diff --git a/CLAUDE.md b/CLAUDE.md index fb0c138..3b16d64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ set -a; . ./.env 2>/dev/null; set +a ## Что мы строим -Хакатон MedITron, кейс SberMed AI «ИИ-помощник пациента». **Сопровождение восстановления между визитами к врачу** при ПТСР и депрессии (фокус — ветераны СВО и их семьи): мониторинг после обращения + управление приёмом лекарств. Общее ядро + модули `veteran` / `depression` / `family`, включаемые скринингом. +Хакатон MedITron, кейс SberMed AI «ИИ-помощник пациента». **Сопровождение восстановления между визитами к врачу** при ПТСР и депрессии (фокус — ветераны СВО и их семьи): мониторинг после обращения + управление приёмом лекарств. Общее ядро + модули `veteran` / `depression` / `family`. **Фокус хакатона — ветераны СВО** (эпик #35, демо — ветеран Андрей); семьи и общая популяция — опциональные направления на будущее (ADR-010). ИИ не ставит диагнозы и не лечит: LLM извлекает данные и формулирует реплики, **риск и алерты решают детерминированные правила**, клинические решения — врач. **Дедлайн: пн 05.10.2026, 13:00 МСК.** Критерии жюри: работоспособность, функциональность, дизайн, клинико-экономика, клиентский путь. @@ -60,7 +60,7 @@ set -a; . ./.env 2>/dev/null; set +a 1. **`.env` — первым делом.** 2. **Задача = Issue в Gitea.** Нет issue — нет кода. -3. **Зоны:** core / bot / backend / dashboard / devops / clinical / pitch / docs. В занятую чужую не лезь. +3. **Зоны — слои, а не направления:** lane A бот · lane B backend · lane C дашборд (владение файлами — `docs/dev/WORKFLOW.md`). Контракты между ними заморожены (#34) — правка только PR `[contract]`. 4. **Задача = свой worktree** (`.claude/worktrees/-`), ветка от `origin/dev` — до первого изменения. PR в `dev`; `main` — только человек. 5. **Завершил → сразу push + PR**, после зелёного CI можно мержить самому (хакатон). 6. **Лог** — в тело issue (`### YYYY-MM-DD`); архитектурные решения — в `docs/DECISIONS.md`. diff --git a/app/api/fixtures.py b/app/api/fixtures.py new file mode 100644 index 0000000..79ea55a --- /dev/null +++ b/app/api/fixtures.py @@ -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 diff --git a/app/api/routes.py b/app/api/routes.py new file mode 100644 index 0000000..f32d0bb --- /dev/null +++ b/app/api/routes.py @@ -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" + ) + ] diff --git a/app/api/schemas.py b/app/api/schemas.py new file mode 100644 index 0000000..6b91285 --- /dev/null +++ b/app/api/schemas.py @@ -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/?start= 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/.jsonl if scripted diff --git a/app/channels/telegram.py b/app/channels/telegram.py new file mode 100644 index 0000000..398ad75 --- /dev/null +++ b/app/channels/telegram.py @@ -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).""" diff --git a/app/core/modules.py b/app/core/modules.py new file mode 100644 index 0000000..40e175f --- /dev/null +++ b/app/core/modules.py @@ -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 diff --git a/app/core/privacy.py b/app/core/privacy.py index 5cd0522..0bee813 100644 --- a/app/core/privacy.py +++ b/app/core/privacy.py @@ -68,9 +68,7 @@ _PHONE_MOBILE = re.compile(r"(? _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,}(?:(?:ов|ев|ёв)(?:а|у|ым|ой|ою|е|ых)?|(?:ск|цк)(?:ий|ая|ого|ому|им|ой|ую|ом)|енко)$" diff --git a/app/core/safety.py b/app/core/safety.py index 2ea5fec..7c476c8 100644 --- a/app/core/safety.py +++ b/app/core/safety.py @@ -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" diff --git a/app/db/repo.py b/app/db/repo.py index 8bbbfb9..a48d888 100644 --- a/app/db/repo.py +++ b/app/db/repo.py @@ -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) diff --git a/app/dialog/contracts.py b/app/dialog/contracts.py new file mode 100644 index 0000000..6dd1914 --- /dev/null +++ b/app/dialog/contracts.py @@ -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() diff --git a/app/integrations/base.py b/app/integrations/base.py new file mode 100644 index 0000000..60490a5 --- /dev/null +++ b/app/integrations/base.py @@ -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() diff --git a/app/llm/gateway.py b/app/llm/gateway.py index e4ae74c..aabfb32 100644 --- a/app/llm/gateway.py +++ b/app/llm/gateway.py @@ -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) diff --git a/app/llm/mock.py b/app/llm/mock.py index d8ca4fd..f7e12a8 100644 --- a/app/llm/mock.py +++ b/app/llm/mock.py @@ -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="") diff --git a/app/main.py b/app/main.py index a118dc5..fec6c61 100644 --- a/app/main.py +++ b/app/main.py @@ -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") diff --git a/app/scheduler/jobs.py b/app/scheduler/jobs.py new file mode 100644 index 0000000..7e6f7b5 --- /dev/null +++ b/app/scheduler/jobs.py @@ -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).""" diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/contracts.py b/app/services/contracts.py new file mode 100644 index 0000000..d9dd969 --- /dev/null +++ b/app/services/contracts.py @@ -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() diff --git a/config/modules.yaml b/config/modules.yaml new file mode 100644 index 0000000..f92f18b --- /dev/null +++ b/config/modules.yaml @@ -0,0 +1,42 @@ +# Clinical modules (ADR-001, docs/info/CLINICAL_MODULES.md). Starting values; final ones are approved by the +# team psychiatrist (#8). Read by the dialog engine (start screening, daily question) and the scheduler (#13). +# +# schedule: a scale is due when `every_days` have passed since it was last completed; `from_day`/`until_day` +# bound the therapy-day window (day 0 = visit where therapy was prescribed). + +core: + daily: [mood, med_taken] # every module: mood 0–10 + dose confirmation + silence_alert_days: 3 # no answer for N days → reach out (silence is a risk signal) + item9_followup: cssrs_screen # PHQ-9 item 9 >= 1 → C-SSRS → escalation + +modules: + veteran: + title: "Ветераны боевых действий" + start_scales: [pcptsd5, pcl5, phq9, auditc] + start_questions: [trauma_exposure, weapon_access] + daily_question: sleep_nightmares + schedule: + - {scale: pcl5, every_days: 14} + - {scale: phq9, every_days: 14} + - {scale: dar5, every_days: 14} + - {scale: auditc, every_days: 30} + enables_depression_if: {scale: phq9, op: ">=", value: 10} + + depression: + title: "Депрессия и суицидальный риск" + start_scales: [phq9, gad7, cssrs_screen] + start_questions: [] + daily_question: null + schedule: + - {scale: phq9, every_days: 7, until_day: 42} # weekly during the first 4–6 weeks + - {scale: phq9, every_days: 14, from_day: 43} + - {scale: cssrs_screen, every_days: 7} + enhanced_monitoring_under_age: 25 # FDA warning: young adults early in antidepressant therapy + + family: # optional future direction (epic #40) + title: "Близкие ветеранов" + start_scales: [phq4] + start_questions: [hits] + daily_question: null + schedule: + - {scale: phq4, every_days: 30} diff --git a/config/scales/dar5.yaml b/config/scales/dar5.yaml new file mode 100644 index 0000000..7d0630c --- /dev/null +++ b/config/scales/dar5.yaml @@ -0,0 +1,26 @@ +# Стартовые пороги из литературы, финальные утверждает психиатр (issue #8). +# DAR-5 (Dimensions of Anger Reactions, Forbes et al., 2014): 5 пунктов × 1–5, сумма 5–25; ≥ 12 — клинически значимый гнев. +id: dar5 +title: "Шкала реакций гнева (DAR-5)" +recall_period: "последние 4 недели" +instruction: "Насколько следующие утверждения описывают Вас за последние 4 недели?" +license_note: "DAR-5 — лицензия на проверке (CLINICAL_MODULES.md), в прототипе демо-текст." +translation_note: "Рабочий перевод; финальную русскую версию выбирает психиатр (#8)." +options: + - {value: 1, label: "Ни разу"} + - {value: 2, label: "Немного времени"} + - {value: 3, label: "Часть времени"} + - {value: 4, label: "Большую часть времени"} + - {value: 5, label: "Всё время"} +items: + - {id: dar5_1, text: "Я злился(лась) на людей или ситуации"} + - {id: dar5_2, text: "Когда я злился(лась), я по-настоящему выходил(а) из себя"} + - {id: dar5_3, text: "Когда я злился(лась), злость не проходила долго"} + - {id: dar5_4, text: "Когда я злился(лась), это мешало мне в повседневной жизни"} + - {id: dar5_5, text: "Мой гнев мешал отношениям с близкими"} +range: {min: 5, max: 25} +severity: + - {min: 5, max: 11, code: below_threshold, label: "Ниже порога"} + - {min: 12, max: 25, code: significant, label: "Клинически значимый гнев"} +flags: + - {code: dar5_significant, when: {total: true, op: ">=", value: 12}} diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 18940f3..b327e56 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -3,6 +3,12 @@ > Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение. > При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR. +## 2026-10-03 — ADR-010: Фокус — ветераны СВО; разработчики делят слои, а не направления +**Контекст:** три направления (ветераны, семьи, общая популяция с депрессией) и три разработчика. Если каждый ведёт своё направление, все трое правят одни и те же файлы: движок, API, дашборд. +**Решение:** на хакатоне делаем только ветеранов (эпик #35); семьи (#40) и общая популяция (#43) — опциональные направления на будущее, без срока. Разработчики делят **слои**: A — бот (диалог, каналы, LLM), B — backend (API, сервисы, шедулер, адаптеры, БД, ядро), C — дашборд. Между слоями — замороженные контракты (#34) с заглушками, поэтому все трое стартуют одновременно. Направления подключаются как `app/dialog/flows/.py` + `config/modules.yaml`. +**Причина:** ноль коллизий по файлам при параллельной работе; общее ядро переиспользуется направлениями, как и задумано в ADR-001. Главный герой демо — Андрей (решение #9). +**Последствия:** изменения контрактов — только мелкими PR `[contract]` с пингом всех. Владение файлами — в `docs/dev/WORKFLOW.md`. + ## 2026-10-03 — ADR-009: Процесс — регламент TenVex в облегчённом режиме хакатона **Контекст:** 3 дня, сдача в пн 05.10 в 13:00 МСК. Два разработчика и Claude Code-агенты работают параллельно. Опыт TenVex/vobot: без issue и изоляции агенты конфликтуют. **Решение:** берём ядро регламента vobot: задача = issue, одна зона на issue, worktree + ветка от `origin/dev`, PR в `dev`, Conventional Commits, лог в теле issue, ADR сюда. Облегчения: PR в `dev` можно мержить самому после зелёного `make lint test`, без обязательного ревью; contract-first/orval нет; `main` = сдаваемая версия, промоушн `dev → main` делает человек. diff --git a/docs/dev/WORKFLOW.md b/docs/dev/WORKFLOW.md index f86138c..7578472 100644 --- a/docs/dev/WORKFLOW.md +++ b/docs/dev/WORKFLOW.md @@ -2,37 +2,52 @@ > Основан на регламенте TenVex/vobot, облегчён под хакатон (ADR-009). Дедлайн: **пн 05.10.2026, 13:00 МСК**. -## Зоны и файлы +## Зоны (lanes) и файлы -| Зона (`label`) | Пути | Отвечает за | Владелец | +Три разработчика делят **слои**, а не направления (ADR-010): каждый ведёт свой слой для всех сценариев. У каждой зоны свои файлы — в чужие не заходим, поэтому коллизий нет. + +| Lane | Кто | Владеет файлами | Метки зон | |---|---|---|---| -| `core` | `app/core/` | privacy, шкалы и скоринг, safety, движок правил, демо-часы | dev | -| `bot` | `app/dialog/`, `app/channels/`, `app/llm/` | FSM диалога, Telegram-адаптер, LLM-шлюз (GigaChat/mock) | Егор | -| `backend` | `app/db/`, `app/api/`, `app/scheduler/`, `app/integrations/` | БД, API, шедулер, моки МИС/ЕСИА/ГРЛС, уведомления | dev | -| `dashboard` | `dashboard/` | дашборд врача, демо-консоль (Streamlit) | dev | -| `devops` | `docker-compose.yml`, `Dockerfile`, `Makefile`, `.gitea/`, `pyproject.toml` | окружение, CI, запуск на чистой машине | dev | -| `clinical` | `config/` (содержимое), `data/eval/` | шкалы, пороги, тексты, лексикон, кризисный скрипт | психиатр | -| `pitch` | `docs/pitch/` | презентация, клинико-экономика, CJM, правовые слайды | медик | -| `docs` | `docs/`, `README.md`, `CLAUDE.md` | регламенты, ADR, README | все | +| **A — бот** | Егор (`Eleboba`) | `app/dialog/**`, `app/channels/**`, `app/llm/**`, `config/texts/**`, `tests/test_dialog_*`, `tests/test_telegram*`, `tests/test_llm*` | `bot`, `lane/A` | +| **B — backend** | разработчик 2 | `app/api/**` (кроме `schemas.py`), `app/services/**` (кроме `contracts.py`), `app/scheduler/**`, `app/integrations/**` (кроме `base.py`), `app/db/**`, `app/core/**` (правки — мелким PR), `scripts/**`, `data/**`, `tests/test_api*`, `tests/test_services*`, `tests/test_scheduler*`, `tests/test_seed*`, `tests/test_integrations*`, `tests/test_db*` | `backend`, `core`, `lane/B` | +| **C — дашборд** | разработчик 3 | `dashboard/**`, `tests/test_dashboard*` | `dashboard`, `lane/C` | +| clinical | психиатр | значения в `config/scales/*`, `config/rules.yaml`, `config/lexicon/*`, вычитка `config/texts/*` | `clinical` | +| pitch | медик | `docs/pitch/**` | `pitch` | -**Замок зоны:** пока на зону есть открытый issue с активной веткой, файлы зоны принадлежат ему до мержа. Перед правкой: `git fetch origin && git branch -r`. -**Схемы `config/*.yaml`** задаёт `core` (pydantic-модели), а **значения** в них — `clinical`. +**Замороженные контракты** (меняются только отдельным мелким PR `[contract]` с пингом всех трёх): +`app/core/types.py`, `app/dialog/contracts.py`, `app/api/schemas.py`, `app/integrations/base.py`, `app/services/contracts.py`, `app/main.py`, `app/config.py`, `config/modules.yaml`, `config/rules.yaml` (схема), `pyproject.toml`/`uv.lock`, `docker-compose.yml`, `Makefile`, `.gitea/`. + +**Как зоны стыкуются без ожидания друг друга:** +- A → B: движок вызывает `get_monitoring().after_data_change()` и `integrations.*`. До готовности B это заглушки (`NoopMonitoring`, `Memory*`). +- B → A: API и шедулер вызывают `get_engine().handle()`. До готовности A это `EchoEngine`. +- C → B: дашборд ходит только в `/api/v1`. До #12 ручки отдают фикстуры (`app/api/fixtures.py`). +- Реальная реализация регистрируется сама: `register_engine()` / `register_monitoring()` при импорте модуля, `integrations. = ...` в `app/integrations/setup()`. + +### Направления развития +| Направление | Статус | Milestone | +|---|---|---| +| Ветераны СВО | **фокус хакатона** | `Этап 1 · Ветераны СВО` (+ `M3 Сдача`) | +| Семьи ветеранов | опционально, после хакатона | `Будущее · Семьи ветеранов` (эпик #40) | +| Депрессия в общей популяции | опционально, после хакатона | `Будущее · Депрессия (общая популяция)` (эпик #43) | + +Новое направление = новый файл `app/dialog/flows/.py` + запись в `config/modules.yaml`; движок, API и дашборд расширяются в своих зонах. --- ## Откуда задачи - Только из Gitea `$GITEA_REPO`. Нет issue — нет кода. Нет подходящего issue — заведи его на себя. -- Атрибуты: **метка зоны**, **тип** (`feature`/`fix`/`refactor`/`docs`/`chore`), **размер** (`size/0.5` ≈ 2 ч, `size/1` ≈ 4 ч, `size/2` ≈ день), **milestone** (M1/M2/M3), приоритет `P0`/`P1`/`P2`. +- Атрибуты: **метка зоны** + `lane/A|B|C`, **тип** (`feature`/`fix`/`refactor`/`docs`/`chore`), **размер** (`size/0.5` ≈ 2 ч, `size/1` ≈ 4 ч, `size/2` ≈ день), **milestone** (M1/M2/M3), приоритет `P0`/`P1`/`P2`. - **Одна задача = одна зона.** Нужно несколько зон → несколько issue, связанных зависимостью через API ([`GITEA_API.md`](GITEA_API.md)). - Тело issue — по шаблону [`ISSUE_TEMPLATE.md`](ISSUE_TEMPLATE.md). ### Milestones | Milestone | Срок | Смысл | |---|---|---| -| M1 Каркас | сб 03.10, 23:59 | ядро, БД, LLM-шлюз, конфиги; тесты зелёные | -| M2 Демо-сценарий | вс 04.10, 23:59 | весь сценарий проходит end-to-end в демо-консоли | +| M1 Каркас | сб 03.10 ✅ | ядро, БД, LLM-шлюз, конфиги | +| Этап 1 · Ветераны СВО | вс 04.10, 23:59 | демо Андрея end-to-end (эпик #35) | | M3 Сдача | пн 05.10, 13:00 | README, презентация PDF, репетиция, `dev → main` | +| Будущее · … | без срока | опциональные направления | --- @@ -78,7 +93,7 @@ scope = зона. `Closes #N` — только в описании PR. Closes # ## Зона -core | bot | backend | dashboard | devops | clinical | pitch | docs +A | B | C | clinical | pitch | docs | contract ## Как проверить шаги/команды diff --git a/docs/product/01_demo_persona.md b/docs/product/01_demo_persona.md index 523b800..5e25068 100644 --- a/docs/product/01_demo_persona.md +++ b/docs/product/01_demo_persona.md @@ -2,7 +2,65 @@ Источник для seed-данных и сценария демо. Все данные вымышленные. -## Основная персона +> **Решение #9 / ADR-010:** главный герой хакатона — **ветеран Андрей** (этап 1). Траектория Алексея перенесена на Андрея по структуре. Сценарии Алексея (депрессия, самоскрининг) остаются ниже — это опциональное направление «Депрессия в общей популяции» (эпик #43). + +## Главная персона этапа 1 — Андрей + +| Поле | Значение | +|---|---| +| Кто | Андрей С., 34 года, контрактник, вернулся после ранения 8 месяцев назад; райцентр, до психиатра — областной центр | +| Жалобы | плохо спит, кошмары, раздражительность, срывается на домашних, стал чаще выпивать | +| Барьер | «я не псих»; страх учёта, лишения прав на оружие и вождение; доверяет только своим | +| Точка входа | углублённая диспансеризация → консультация психолога → психиатр кабинета медико-психологического консультирования предлагает подключиться (QR/код) | +| Скрининг (день −3) | PC-PTSD-5 = 4 (положительный), PCL-5 = 46, PHQ-9 = 14 (п.9 = 0), AUDIT-C = 3 (ниже порога для мужчин), оружия дома нет (охотничье ружьё у брата) | +| Визит (день 0) | ПТСР + депрессивный эпизод средней степени; **сертралин 50 мг 1 р/сут утром после еды**; модули `veteran` + `depression`; следующий визит через 4 недели | +| Врач | д-р Смирнова Е. А., психиатр (`doc-smirnova`) | + +### Траектория Андрея (сценарий этапа 1) + +Ожидаемые цвета проверяются тестом правил: 🟢🟢🟢🟡🟡🟢🟢🟢🟢🟡🟡🟡🔴. + +| День | Приём | Настр. 0–10 | Сон | Кошмары | Алкоголь | Свободный текст | Шкалы | Уровень | Реакция системы | +|---|---|---|---|---|---|---|---|---|---| +| 0 | — | — | — | — | — | — | — | 🟢 | врач «Подключить пациента» → код; Андрей: согласие, знакомство, напоминания | +| 1 | да | 4 | 4–6 | да | нет | — | — | 🟢 | приветствие, напоминание, вопрос про сон | +| 2 | да | 4 | 4–6 | да | нет | «подташнивает с утра» | — | 🟢 | med_qa: частая побочка, обычно проходит за 1–2 нед.; принимать после еды | +| 3 | да | 4 | 4–6 | да | нет | «опять тошнит» | — | 🟢 | поддержка | +| 4 | да | 3 | 4–6 | нет | нет | «тошнота не проходит» | — | 🟡 | побочка 3 дня подряд → алерт `side_effect_3d` | +| 5 | **нет** | 3 | 4–6 | нет | нет | «от таблеток только хуже, брошу» | — | 🟡 | отсроченный эффект, не отменять без врача; алерт `quit_intent` | +| 6 | да | 4 | 6–8 | нет | нет | «врач позвонил, продолжаю» | — | 🟢 | врач подтвердил алерты (ack) после звонка | +| 7 | да | 4 | 6–8 | нет | нет | — | PCL-5 = 44, PHQ-9 = 13, п.9 = 0 | 🟢 | плановые шкалы | +| 8 | да | 4 | < 4 | да | 1–2 | «снится одно и то же» | — | 🟢 | — | +| 9 | да | 4 | < 4 | да | больше | — | — | 🟢 | — | +| 10 | да | 4 | < 4 | да | больше | «без пива не могу уснуть» | — | 🟡 | сон < 4 ч три ночи (`sleep_lt4_3n`) + алкоголь 3 дня (`alcohol_3d`) → инфо о взаимодействии с сертралином, предложение записаться раньше | +| 11 | да | 3 | 4–6 | нет | 1–2 | — | — | 🟡 | (алерты открыты) | +| 12 | да | 3 | 4–6 | нет | нет | — | — | 🟡 | — | +| 13 | да | 2 | < 4 | да | нет | «всё бессмысленно, устал от всего» | внеплановый PHQ-9 = 18, п.9 = 2 | 🔴 | настроение ≤ 3 три дня → внеплановый PHQ-9 → п.9 ≥ 1 → кризисный скрипт, C-SSRS, план безопасности, алерт `phq9_item9` дежурному ≤ 3 с | + +### Дашборд: ещё 7 пациентов кабинета + +| Пациент | Возраст | Модули | Препарат | День | Статус | Последнее событие | +|---|---|---|---|---|---|---| +| Дмитрий В. | 41 | veteran | сертралин 100 мг | 45 | 🟡 | кошмары, сон < 4 ч 3 ночи | +| Сергей П. | 37 | veteran | пароксетин 20 мг | 30 | 🟡 | пропуски 3 из 7 дней | +| Николай Ф. | 52 | veteran | сертралин 50 мг | 14 | 🟡 | алкоголь 4 из 7 дней | +| Анна Л. | 23 | depression | флуоксетин 20 мг | 8 | 🟡 | возраст < 25: усиленный мониторинг | +| Ирина К. | 34 | depression | эсциталопрам 10 мг | 21 | 🟢 | PHQ-9 16 → 9 | +| Ольга Н. | 29 | depression | сертралин 50 мг | 10 | 🟢 | тошнота прошла | +| Павел Р. | 45 | depression | эсциталопрам 10 мг | 60 | 🟢 | ремиссия, PHQ-9 = 4 | + +### Порядок демо этапа 1 (≈ 3 мин) +1. Врач подключает Андрея на дашборде → QR — 15 с +2. Андрей: согласие, короткий скрининг, напоминание о сертралине → «принял» — 40 с +3. День 5: «брошу» → объяснение + 🟡 у врача — 35 с +4. День 10: кошмары, сон, алкоголь → инфо о взаимодействии, «записаться раньше» — 30 с +5. День 13: «всё бессмысленно» → внеплановый PHQ-9 → 🔴 кризисный протокол + карточка алерта и план безопасности на дашборде — 40 с + +--- + +# Опциональное направление: депрессия в общей популяции (Алексей) + +## Персона Алексея | Поле | Значение | |---|---| diff --git a/pyproject.toml b/pyproject.toml index 0368c72..e1afa3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "plotly>=5.22", "pandas>=2.2", "qrcode[pil]>=7.4", + "tzdata>=2026.5", ] [dependency-groups] @@ -37,7 +38,7 @@ build-backend = "hatchling.build" packages = ["app"] [tool.ruff] -line-length = 110 +line-length = 120 target-version = "py311" extend-exclude = [".claude", "docs"] diff --git a/tests/test_contracts.py b/tests/test_contracts.py new file mode 100644 index 0000000..4063380 --- /dev/null +++ b/tests/test_contracts.py @@ -0,0 +1,21 @@ +from fastapi.testclient import TestClient + +from app.dialog.contracts import Event, get_engine +from app.main import app +from app.services.contracts import get_monitoring + + +async def test_stubs_are_usable() -> None: + replies = await get_engine().handle("tok", Event(type="text", text="привет")) + assert replies and replies[0].text + assert await get_monitoring().after_data_change("tok") == [] + + +def test_api_fixture_routes() -> None: + with TestClient(app) as c: + patients = c.get("/api/v1/doctors/doc-smirnova/patients").json() + assert patients[0]["status_color"] == "red" + card = c.get("/api/v1/patients/tok-andrey", params={"doctor_id": "doc-smirnova"}).json() + assert card["safety_plan"] and card["series"] + assert c.post("/api/v1/dialog/tok-andrey/events", json={"type": "text", "text": "ok"}).status_code == 200 + assert c.get("/api/v1/demo/clock").json()["virtual"] is True diff --git a/tests/test_db.py b/tests/test_db.py index 9dd80aa..500dc29 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -73,9 +73,7 @@ async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None: async def test_identity_roundtrip_and_connect_code(dbs: None) -> None: async with session.get_identity_session() as s: - await identity_repo.upsert_doctor( - s, doctor_id="d1", full_name="Доктор Тест", clinic_id="c1", on_call=True - ) + await identity_repo.upsert_doctor(s, doctor_id="d1", full_name="Доктор Тест", clinic_id="c1", on_call=True) await identity_repo.create_patient( s, token=TOKEN, @@ -147,9 +145,7 @@ async def test_scales_profiles_prescriptions(dbs: None) -> None: await repo.upsert_profile(s, token=TOKEN, doctor_id="d1", modules=["veteran", "depression"]) await repo.upsert_profile(s, token=TOKEN2, doctor_id="d2") await repo.upsert_profile(s, token=TOKEN, next_visit=date(2026, 10, 20)) - await repo.add_item_response( - s, token=TOKEN, scale="phq9", item_id="9", score=1, source="button", ts=T0 - ) + await repo.add_item_response(s, token=TOKEN, scale="phq9", item_id="9", score=1, source="button", ts=T0) await repo.add_scale_result( s, token=TOKEN, @@ -218,9 +214,7 @@ async def test_dialog_state_messages_clock(dbs: None) -> None: async with session.get_clinical_session() as s: assert await repo.get_dialog_state(s, TOKEN) is None await repo.set_dialog_state(s, token=TOKEN, state="checkin_mood", data={"step": 1}, now=T0) - await repo.add_message( - s, token=TOKEN, direction="in", text=ScrubbedText("ок"), ts=T0, fsm_state="idle" - ) + await repo.add_message(s, token=TOKEN, direction="in", text=ScrubbedText("ок"), ts=T0, fsm_state="idle") with pytest.raises(ValueError): await repo.add_message(s, token=TOKEN, direction="x", text=ScrubbedText("ок"), ts=T0) assert await repo.load_clock_offset(s) == 0.0 @@ -237,9 +231,7 @@ async def test_dialog_state_messages_clock(dbs: None) -> None: async def test_naive_datetime_rejected(dbs: None) -> None: with pytest.raises(Exception, match="naive datetime"): async with session.get_clinical_session() as s: - await repo.add_alert( - s, token=TOKEN, level=AlertLevel.INFO, code="x", reason="x", ts=datetime(2026, 1, 1) - ) + await repo.add_alert(s, token=TOKEN, level=AlertLevel.INFO, code="x", reason="x", ts=datetime(2026, 1, 1)) async def test_session_rolls_back_on_error(dbs: None) -> None: diff --git a/tests/test_modules.py b/tests/test_modules.py new file mode 100644 index 0000000..4ba23f7 --- /dev/null +++ b/tests/test_modules.py @@ -0,0 +1,33 @@ +from datetime import date, timedelta + +from app.core.modules import due_scales, load_modules +from app.core.scales import load_all + +TODAY = date(2026, 10, 3) + + +def test_modules_reference_existing_scales() -> None: + cfg, scales = load_modules(), load_all() + for mod in cfg.modules.values(): + for s in mod.start_scales + [e.scale for e in mod.schedule]: + assert s in scales, s + + +def test_veteran_schedule() -> None: + last = {s: TODAY - timedelta(days=1) for s in ["pcl5", "phq9", "dar5", "auditc"]} + assert due_scales({"veteran"}, 10, last, TODAY) == [] + last["pcl5"] = TODAY - timedelta(days=14) + assert due_scales({"veteran"}, 20, last, TODAY) == ["pcl5"] + + +def test_depression_weekly_then_biweekly() -> None: + last = {"phq9": TODAY - timedelta(days=7), "cssrs_screen": TODAY} + assert due_scales({"depression"}, 20, last, TODAY) == ["phq9"] + assert due_scales({"depression"}, 50, last, TODAY) == [] + + +def test_dar5_scoring() -> None: + from app.core.scales import score + + r = score("dar5", {f"dar5_{i}": 3 for i in range(1, 6)}) + assert r.total == 15 and "dar5_significant" in r.flags diff --git a/tests/test_privacy.py b/tests/test_privacy.py index 31abfc1..3101ba8 100644 --- a/tests/test_privacy.py +++ b/tests/test_privacy.py @@ -191,9 +191,7 @@ def test_pseudo_id_default_key_from_settings() -> None: assert pseudo_id("11223344595") == pseudo_id("11223344595", key=get_settings().pseudo_key) -@pytest.mark.parametrize( - "bad", ["", "1234567890", "123456789012", "112-233-445 9X", "11223344595"] -) +@pytest.mark.parametrize("bad", ["", "1234567890", "123456789012", "112-233-445 9X", "11223344595"]) def test_pseudo_id_rejects_invalid_snils(bad: str) -> None: with pytest.raises(ValueError): pseudo_id(bad, key=KEY) diff --git a/tests/test_rules.py b/tests/test_rules.py index f18f9b5..7e58f8a 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -60,9 +60,7 @@ def test_yaml_loads_and_every_rule_has_evaluator() -> None: def test_unknown_rule_id_rejected() -> None: with pytest.raises(ValidationError): - RulesConfig.model_validate( - {"rules": [{"id": "nope", "actions": [{"type": "alert", "level": "info"}]}]} - ) + RulesConfig.model_validate({"rules": [{"id": "nope", "actions": [{"type": "alert", "level": "info"}]}]}) def test_alert_without_level_rejected() -> None: @@ -134,9 +132,7 @@ def test_cssrs_levels() -> None: (None, "moderate", None, False), ], ) -def test_weapon_access_with_risk( - weapon: bool | None, cssrs: str | None, item9: int | None, fires: bool -) -> None: +def test_weapon_access_with_risk(weapon: bool | None, cssrs: str | None, item9: int | None, fires: bool) -> None: scales = [] if item9 is None else [ScalePoint(scale="phq9", ts=at(5, 20), total=10, item9=item9)] s = snap(5, weapon_access=weapon, cssrs_risk=cssrs, scale_results=scales) assert ("weapon_access_risk" in codes(evaluate(s), "crisis")) is fires @@ -188,9 +184,7 @@ def test_future_scale_results_ignored() -> None: assert evaluate(s) == [] -@pytest.mark.parametrize( - ("sex", "total", "fires"), [("m", 4, True), ("m", 3, False), ("f", 3, True), ("f", 2, False)] -) +@pytest.mark.parametrize(("sex", "total", "fires"), [("m", 4, True), ("m", 3, False), ("f", 3, True), ("f", 2, False)]) def test_auditc_by_sex(sex: str, total: int, fires: bool) -> None: s = snap(1, sex=sex, scale_results=[ScalePoint(scale="auditc", ts=at(0), total=total)]) acts = evaluate(s) @@ -212,9 +206,7 @@ def test_quit_intent_voiced() -> None: def test_quit_intent_followed_by_skipped_dose() -> None: - s = snap( - 7, checkins=[ci(5, med_taken=True, quit_intent=True), ci(6, med_taken=True), ci(7, med_taken=False)] - ) + s = snap(7, checkins=[ci(5, med_taken=True, quit_intent=True), ci(6, med_taken=True), ci(7, med_taken=False)]) assert "quit_intent" in codes(evaluate(s), "alert") no_intent = snap(7, checkins=[ci(6, med_taken=True), ci(7, med_taken=False)]) assert "quit_intent" not in codes(evaluate(no_intent)) @@ -303,9 +295,7 @@ def test_silence_over_3_days() -> None: assert evaluate(snap(10, last_patient_message_at=at(7, 21))) == [] # exactly 3 days -@pytest.mark.parametrize( - ("age", "day", "fires"), [(23, 8, True), (24, 28, True), (25, 8, False), (23, 29, False)] -) +@pytest.mark.parametrize(("age", "day", "fires"), [(23, 8, True), (24, 28, True), (25, 8, False), (23, 29, False)]) def test_young_adult_enhanced_monitoring(age: int, day: int, fires: bool) -> None: acts = evaluate(snap(day, age=age)) assert (acts == [a for a in acts if a.type == "enhanced_monitoring"] and bool(acts)) is fires diff --git a/tests/test_scoring.py b/tests/test_scoring.py index ab0e874..81ee105 100644 --- a/tests/test_scoring.py +++ b/tests/test_scoring.py @@ -29,7 +29,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} + expected = {"phq9": 9, "gad7": 7, "pcl5": 20, "auditc": 3, "pcptsd5": 5, "cssrs_screen": 6, "phq4": 4, "dar5": 5} assert counts == expected diff --git a/uv.lock b/uv.lock index d25ad73..fa2c0d6 100644 --- a/uv.lock +++ b/uv.lock @@ -1088,6 +1088,7 @@ dependencies = [ { name = "qrcode", extra = ["pil"] }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "streamlit" }, + { name = "tzdata" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -1115,6 +1116,7 @@ requires-dist = [ { name = "qrcode", extras = ["pil"], specifier = ">=7.4" }, { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, { name = "streamlit", specifier = ">=1.38" }, + { name = "tzdata", specifier = ">=2026.5" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, ] -- 2.54.0