chore(contract): контракты этапа 1 — диалог, API + фикстуры, адаптеры, мониторинг, модули #47
@@ -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 извлекает данные и формулирует реплики, **риск и алерты решают детерминированные правила**, клинические решения — врач.
|
ИИ не ставит диагнозы и не лечит: LLM извлекает данные и формулирует реплики, **риск и алерты решают детерминированные правила**, клинические решения — врач.
|
||||||
|
|
||||||
**Дедлайн: пн 05.10.2026, 13:00 МСК.** Критерии жюри: работоспособность, функциональность, дизайн, клинико-экономика, клиентский путь.
|
**Дедлайн: пн 05.10.2026, 13:00 МСК.** Критерии жюри: работоспособность, функциональность, дизайн, клинико-экономика, клиентский путь.
|
||||||
@@ -60,7 +60,7 @@ set -a; . ./.env 2>/dev/null; set +a
|
|||||||
|
|
||||||
1. **`.env` — первым делом.**
|
1. **`.env` — первым делом.**
|
||||||
2. **Задача = Issue в Gitea.** Нет issue — нет кода.
|
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/<issue#>-<slug>`), ветка от `origin/dev` — до первого изменения. PR в `dev`; `main` — только человек.
|
4. **Задача = свой worktree** (`.claude/worktrees/<issue#>-<slug>`), ветка от `origin/dev` — до первого изменения. PR в `dev`; `main` — только человек.
|
||||||
5. **Завершил → сразу push + PR**, после зелёного CI можно мержить самому (хакатон).
|
5. **Завершил → сразу push + PR**, после зелёного CI можно мержить самому (хакатон).
|
||||||
6. **Лог** — в тело issue (`### YYYY-MM-DD`); архитектурные решения — в `docs/DECISIONS.md`.
|
6. **Лог** — в тело issue (`### YYYY-MM-DD`); архитектурные решения — в `docs/DECISIONS.md`.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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)."""
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Clinical modules config (`config/modules.yaml`, ADR-001) and scale scheduling.
|
||||||
|
|
||||||
|
Shared by the dialog engine (lane A: start screening, daily question) and the scheduler (lane B: due scales).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
ModuleId = Literal["veteran", "depression", "family"]
|
||||||
|
|
||||||
|
|
||||||
|
class _Strict(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ScheduleEntry(_Strict):
|
||||||
|
scale: str
|
||||||
|
every_days: int = Field(gt=0)
|
||||||
|
from_day: int = 0
|
||||||
|
until_day: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Threshold(_Strict):
|
||||||
|
scale: str
|
||||||
|
op: Literal[">=", ">"]
|
||||||
|
value: int
|
||||||
|
|
||||||
|
|
||||||
|
class ModuleDef(_Strict):
|
||||||
|
title: str
|
||||||
|
start_scales: list[str]
|
||||||
|
start_questions: list[str] = Field(default_factory=list)
|
||||||
|
daily_question: str | None = None
|
||||||
|
schedule: list[ScheduleEntry] = Field(default_factory=list)
|
||||||
|
enables_depression_if: Threshold | None = None
|
||||||
|
enhanced_monitoring_under_age: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CoreDef(_Strict):
|
||||||
|
daily: list[str]
|
||||||
|
silence_alert_days: int
|
||||||
|
item9_followup: str
|
||||||
|
|
||||||
|
|
||||||
|
class ModulesConfig(_Strict):
|
||||||
|
core: CoreDef
|
||||||
|
modules: dict[ModuleId, ModuleDef]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def load_modules(path: Path | None = None) -> ModulesConfig:
|
||||||
|
path = path or get_settings().config_dir / "modules.yaml"
|
||||||
|
return ModulesConfig.model_validate(yaml.safe_load(path.read_text(encoding="utf-8")))
|
||||||
|
|
||||||
|
|
||||||
|
def due_scales(
|
||||||
|
modules: set[str], therapy_day: int, last_done: dict[str, date], today: date, config: ModulesConfig | None = None
|
||||||
|
) -> list[str]:
|
||||||
|
"""Scales due today for a patient: per enabled module, a scale is due if never done in its window or
|
||||||
|
`every_days` have passed since it was last completed. Deduplicated, in config order."""
|
||||||
|
cfg = config or load_modules()
|
||||||
|
out: list[str] = []
|
||||||
|
for mod_id, mod in cfg.modules.items():
|
||||||
|
if mod_id not in modules:
|
||||||
|
continue
|
||||||
|
for e in mod.schedule:
|
||||||
|
if therapy_day < e.from_day or (e.until_day is not None and therapy_day > e.until_day):
|
||||||
|
continue
|
||||||
|
last = last_done.get(e.scale)
|
||||||
|
if (last is None or (today - last).days >= e.every_days) and e.scale not in out:
|
||||||
|
out.append(e.scale)
|
||||||
|
return out
|
||||||
+3
-9
@@ -68,9 +68,7 @@ _PHONE_MOBILE = re.compile(r"(?<![\d\w])\(?9\d{2}\)?[ \-]?\d{3}[ \-]?\d{2}[ \-]?
|
|||||||
# Local 7-digit number with mandatory dashes (123-45-67); dashes keep "4-6 часов" safe.
|
# 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\-])")
|
_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: "серия 4510 номер 123456" or a bare "45 10 123456" / "4510 123456".
|
||||||
_PASSPORT_KW = re.compile(
|
_PASSPORT_KW = re.compile(r"серия\s*:?\s*\d{2}\s?\d{2}\s*,?\s*(?:(?:№|номер|n)\s*:?\s*)?\d{6}(?!\d)", re.IGNORECASE)
|
||||||
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)")
|
_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)")
|
_SNILS_PLAIN = re.compile(r"(?<!\d)\d{11}(?!\d)")
|
||||||
_INN = re.compile(r"(?<!\d)(?:\d{12}|\d{10})(?!\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),
|
re.compile(r"(?<!\d)(?:19|20)\d{2}\s*(?:г\.\s*р\.?|г\.р\.?|года\s+рождения)", re.IGNORECASE),
|
||||||
]
|
]
|
||||||
# "родился в 1985", "дата рождения: 1985" — replace only the year.
|
# "родился в 1985", "дата рождения: 1985" — replace only the year.
|
||||||
_BIRTH_YEAR = re.compile(
|
_BIRTH_YEAR = re.compile(r"((?:родил(?:ся|ась)|рождения)\s*:?\s*(?:в\s+)?)((?:19|20)\d{2})(?!\d)", re.IGNORECASE)
|
||||||
r"((?:родил(?:ся|ась)|рождения)\s*:?\s*(?:в\s+)?)((?:19|20)\d{2})(?!\d)", re.IGNORECASE
|
|
||||||
)
|
|
||||||
|
|
||||||
_STREET_TYPE = (
|
_STREET_TYPE = (
|
||||||
r"(?:ул\.|улица|улице|улицу|улицы|пр-т|пр-кт|просп\.|проспект|проспекте|пер\.|переулок|переулке"
|
r"(?:ул\.|улица|улице|улицу|улицы|пр-т|пр-кт|просп\.|проспект|проспекте|пер\.|переулок|переулке"
|
||||||
@@ -266,9 +262,7 @@ def _name_index() -> _NameIndex:
|
|||||||
_TOKEN = re.compile(r"[А-ЯЁа-яё]+(?:-[А-ЯЁа-яё]+)*")
|
_TOKEN = re.compile(r"[А-ЯЁа-яё]+(?:-[А-ЯЁа-яё]+)*")
|
||||||
_CAPITALISED = re.compile(r"^[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)*$")
|
_CAPITALISED = re.compile(r"^[А-ЯЁ][а-яё]+(?:-[А-ЯЁ][а-яё]+)*$")
|
||||||
# Patronymics: -ович/-евич/-ьич (+ case endings), -овна/-евна/-ична (+ case endings).
|
# Patronymics: -ович/-евич/-ьич (+ case endings), -овна/-евна/-ична (+ case endings).
|
||||||
_PATRONYMIC = re.compile(
|
_PATRONYMIC = re.compile(r"^[а-яё]+(?:(?:(?:ов|ев|ёв)ич|ьич|инич)(?:а|у|ем|е)?|(?:(?:ов|ев|ёв)н|ичн)(?:а|ы|е|у|ой))$")
|
||||||
r"^[а-яё]+(?:(?:(?:ов|ев|ёв)ич|ьич|инич)(?:а|у|ем|е)?|(?:(?:ов|ев|ёв)н|ичн)(?:а|ы|е|у|ой))$"
|
|
||||||
)
|
|
||||||
# Strong surname suffixes: -ов/-ев/-ёв, -ский/-цкий, -енко with case endings.
|
# Strong surname suffixes: -ов/-ев/-ёв, -ский/-цкий, -енко with case endings.
|
||||||
_SURNAME_STRONG = re.compile(
|
_SURNAME_STRONG = re.compile(
|
||||||
r"^[а-яё]{2,}(?:(?:ов|ев|ёв)(?:а|у|ым|ой|ою|е|ых)?|(?:ск|цк)(?:ий|ая|ого|ому|им|ой|ую|ом)|енко)$"
|
r"^[а-яё]{2,}(?:(?:ов|ев|ёв)(?:а|у|ым|ой|ою|е|ых)?|(?:ск|цк)(?:ий|ая|ого|ому|им|ой|ую|ом)|енко)$"
|
||||||
|
|||||||
+2
-6
@@ -320,9 +320,7 @@ def _has_affirmed_match(n: _Normalised, rule: _Rule, neg: _NegationCfg) -> bool:
|
|||||||
# ---------------------------------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def check(
|
def check(text: ScrubbedText, llm_signal: RiskLevel | None = None, *, lexicon: Lexicon | None = None) -> SafetyResult:
|
||||||
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."""
|
"""Classify crisis risk of one message. Deterministic; the LLM signal can only raise the level."""
|
||||||
lex = lexicon or _default_lexicon()
|
lex = lexicon or _default_lexicon()
|
||||||
n = _normalise(text)
|
n = _normalise(text)
|
||||||
@@ -405,9 +403,7 @@ def _render(body: str, resources: list[CrisisResource], doctor_notified: str, gr
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def crisis_reply(
|
def crisis_reply(categories: list[str], audience: Audience = "patient", *, path: Path = CRISIS_PATH) -> CrisisScript:
|
||||||
categories: list[str], audience: Audience = "patient", *, path: Path = CRISIS_PATH
|
|
||||||
) -> CrisisScript:
|
|
||||||
"""Fixed crisis script for an ACUTE result. Never generated by the LLM."""
|
"""Fixed crisis script for an ACUTE result. Never generated by the LLM."""
|
||||||
cfg = _crisis_cfg(path)
|
cfg = _crisis_cfg(path)
|
||||||
variant = f"{audience}.default"
|
variant = f"{audience}.default"
|
||||||
|
|||||||
+5
-15
@@ -82,9 +82,7 @@ async def patients_of_doctor(session: AsyncSession, doctor_id: str) -> Sequence[
|
|||||||
# --- daily check-ins and scales ---------------------------------------------------------------------
|
# --- daily check-ins and scales ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
async def upsert_checkin(
|
async def upsert_checkin(session: AsyncSession, *, token: str, day: date, now: datetime, **fields: Any) -> DailyCheckin:
|
||||||
session: AsyncSession, *, token: str, day: date, now: datetime, **fields: Any
|
|
||||||
) -> DailyCheckin:
|
|
||||||
"""Merge answers into the (token, day) check-in.
|
"""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
|
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
|
unknown = set(fields) - _CHECKIN_FIELDS
|
||||||
if unknown:
|
if unknown:
|
||||||
raise ValueError(f"unknown check-in fields: {sorted(unknown)}")
|
raise ValueError(f"unknown check-in fields: {sorted(unknown)}")
|
||||||
row = await session.scalar(
|
row = await session.scalar(select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == day))
|
||||||
select(DailyCheckin).where(DailyCheckin.token == token, DailyCheckin.day == day)
|
|
||||||
)
|
|
||||||
if row is None:
|
if row is None:
|
||||||
row = DailyCheckin(token=token, day=day, side_effects=[], updated_at=now)
|
row = DailyCheckin(token=token, day=day, side_effects=[], updated_at=now)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
@@ -107,9 +103,7 @@ async def upsert_checkin(
|
|||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
async def list_checkins(
|
async def list_checkins(session: AsyncSession, token: str, since: date | None = None) -> Sequence[DailyCheckin]:
|
||||||
session: AsyncSession, token: str, since: date | None = None
|
|
||||||
) -> Sequence[DailyCheckin]:
|
|
||||||
stmt = select(DailyCheckin).where(DailyCheckin.token == token)
|
stmt = select(DailyCheckin).where(DailyCheckin.token == token)
|
||||||
if since is not None:
|
if since is not None:
|
||||||
stmt = stmt.where(DailyCheckin.day >= since)
|
stmt = stmt.where(DailyCheckin.day >= since)
|
||||||
@@ -176,9 +170,7 @@ async def add_scale_result(
|
|||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
async def list_scale_results(
|
async def list_scale_results(session: AsyncSession, token: str, scale: str | None = None) -> Sequence[ScaleResult]:
|
||||||
session: AsyncSession, token: str, scale: str | None = None
|
|
||||||
) -> Sequence[ScaleResult]:
|
|
||||||
stmt = select(ScaleResult).where(ScaleResult.token == token)
|
stmt = select(ScaleResult).where(ScaleResult.token == token)
|
||||||
if scale is not None:
|
if scale is not None:
|
||||||
stmt = stmt.where(ScaleResult.scale == scale)
|
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())
|
return set(rows.all())
|
||||||
|
|
||||||
|
|
||||||
async def list_alerts(
|
async def list_alerts(session: AsyncSession, *, token: str | None = None, open_only: bool = False) -> Sequence[Alert]:
|
||||||
session: AsyncSession, *, token: str | None = None, open_only: bool = False
|
|
||||||
) -> Sequence[Alert]:
|
|
||||||
stmt = select(Alert)
|
stmt = select(Alert)
|
||||||
if token is not None:
|
if token is not None:
|
||||||
stmt = stmt.where(Alert.token == token)
|
stmt = stmt.where(Alert.token == token)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
@@ -168,9 +168,7 @@ def get_gateway() -> LLMGateway:
|
|||||||
|
|
||||||
|
|
||||||
# Module-level shortcuts, so callers can `from app.llm import gateway; await gateway.extract_item(...)`.
|
# Module-level shortcuts, so callers can `from app.llm import gateway; await gateway.extract_item(...)`.
|
||||||
async def extract_item(
|
async def extract_item(scale_item_id: str, item_text: str, max_score: int, answer: ScrubbedText) -> ItemScore | None:
|
||||||
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)
|
return await get_gateway().extract_item(scale_item_id, item_text, max_score, answer)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -107,9 +107,7 @@ def _extract_item(scale_item_id: str, max_score: int, answer: str) -> ItemScore:
|
|||||||
for fraction, cues in _SEVERITY_CUES:
|
for fraction, cues in _SEVERITY_CUES:
|
||||||
hit = _any(cues, text)
|
hit = _any(cues, text)
|
||||||
if hit:
|
if hit:
|
||||||
return ItemScore(
|
return ItemScore(item=scale_item_id, score=round(fraction * max_score), confidence=0.8, evidence=hit)
|
||||||
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.
|
# 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="")
|
return ItemScore(item=scale_item_id, score=0, confidence=0.3, evidence="")
|
||||||
|
|
||||||
|
|||||||
+10
-2
@@ -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
|
import logging
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
@@ -7,9 +7,12 @@ from contextlib import asynccontextmanager
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from app.api.routes import router
|
||||||
|
from app.channels import telegram
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.db.bootstrap import init_db
|
from app.db.bootstrap import init_db
|
||||||
from app.db.session import dispose
|
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.
|
# 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")
|
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
|
# 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.
|
# 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__)
|
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
|
yield
|
||||||
|
await telegram.stop()
|
||||||
|
await scheduler.stop()
|
||||||
await dispose()
|
await dispose()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="PTSRaiAgent API", version="0.1.0", lifespan=lifespan)
|
app = FastAPI(title="PTSRaiAgent API", version="0.1.0", lifespan=lifespan)
|
||||||
|
app.include_router(router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -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)."""
|
||||||
@@ -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()
|
||||||
@@ -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}
|
||||||
@@ -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}}
|
||||||
@@ -3,6 +3,12 @@
|
|||||||
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
|
> Живой журнал: **что** решили и **почему**. Новые записи — сверху. Одна запись = одно решение.
|
||||||
> При конфликте с [`docs/info/SPEC.md`](info/SPEC.md) действует ADR.
|
> При конфликте с [`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/<module>.py` + `config/modules.yaml`.
|
||||||
|
**Причина:** ноль коллизий по файлам при параллельной работе; общее ядро переиспользуется направлениями, как и задумано в ADR-001. Главный герой демо — Андрей (решение #9).
|
||||||
|
**Последствия:** изменения контрактов — только мелкими PR `[contract]` с пингом всех. Владение файлами — в `docs/dev/WORKFLOW.md`.
|
||||||
|
|
||||||
## 2026-10-03 — ADR-009: Процесс — регламент TenVex в облегчённом режиме хакатона
|
## 2026-10-03 — ADR-009: Процесс — регламент TenVex в облегчённом режиме хакатона
|
||||||
**Контекст:** 3 дня, сдача в пн 05.10 в 13:00 МСК. Два разработчика и Claude Code-агенты работают параллельно. Опыт TenVex/vobot: без issue и изоляции агенты конфликтуют.
|
**Контекст:** 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` делает человек.
|
**Решение:** берём ядро регламента vobot: задача = issue, одна зона на issue, worktree + ветка от `origin/dev`, PR в `dev`, Conventional Commits, лог в теле issue, ADR сюда. Облегчения: PR в `dev` можно мержить самому после зелёного `make lint test`, без обязательного ревью; contract-first/orval нет; `main` = сдаваемая версия, промоушн `dev → main` делает человек.
|
||||||
|
|||||||
+31
-16
@@ -2,37 +2,52 @@
|
|||||||
|
|
||||||
> Основан на регламенте TenVex/vobot, облегчён под хакатон (ADR-009). Дедлайн: **пн 05.10.2026, 13:00 МСК**.
|
> Основан на регламенте TenVex/vobot, облегчён под хакатон (ADR-009). Дедлайн: **пн 05.10.2026, 13:00 МСК**.
|
||||||
|
|
||||||
## Зоны и файлы
|
## Зоны (lanes) и файлы
|
||||||
|
|
||||||
| Зона (`label`) | Пути | Отвечает за | Владелец |
|
Три разработчика делят **слои**, а не направления (ADR-010): каждый ведёт свой слой для всех сценариев. У каждой зоны свои файлы — в чужие не заходим, поэтому коллизий нет.
|
||||||
|
|
||||||
|
| Lane | Кто | Владеет файлами | Метки зон |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `core` | `app/core/` | privacy, шкалы и скоринг, safety, движок правил, демо-часы | dev |
|
| **A — бот** | Егор (`Eleboba`) | `app/dialog/**`, `app/channels/**`, `app/llm/**`, `config/texts/**`, `tests/test_dialog_*`, `tests/test_telegram*`, `tests/test_llm*` | `bot`, `lane/A` |
|
||||||
| `bot` | `app/dialog/`, `app/channels/`, `app/llm/` | FSM диалога, Telegram-адаптер, LLM-шлюз (GigaChat/mock) | Егор |
|
| **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` |
|
||||||
| `backend` | `app/db/`, `app/api/`, `app/scheduler/`, `app/integrations/` | БД, API, шедулер, моки МИС/ЕСИА/ГРЛС, уведомления | dev |
|
| **C — дашборд** | разработчик 3 | `dashboard/**`, `tests/test_dashboard*` | `dashboard`, `lane/C` |
|
||||||
| `dashboard` | `dashboard/` | дашборд врача, демо-консоль (Streamlit) | dev |
|
| clinical | психиатр | значения в `config/scales/*`, `config/rules.yaml`, `config/lexicon/*`, вычитка `config/texts/*` | `clinical` |
|
||||||
| `devops` | `docker-compose.yml`, `Dockerfile`, `Makefile`, `.gitea/`, `pyproject.toml` | окружение, CI, запуск на чистой машине | dev |
|
| pitch | медик | `docs/pitch/**` | `pitch` |
|
||||||
| `clinical` | `config/` (содержимое), `data/eval/` | шкалы, пороги, тексты, лексикон, кризисный скрипт | психиатр |
|
|
||||||
| `pitch` | `docs/pitch/` | презентация, клинико-экономика, CJM, правовые слайды | медик |
|
|
||||||
| `docs` | `docs/`, `README.md`, `CLAUDE.md` | регламенты, ADR, README | все |
|
|
||||||
|
|
||||||
**Замок зоны:** пока на зону есть открытый issue с активной веткой, файлы зоны принадлежат ему до мержа. Перед правкой: `git fetch origin && git branch -r`.
|
**Замороженные контракты** (меняются только отдельным мелким PR `[contract]` с пингом всех трёх):
|
||||||
**Схемы `config/*.yaml`** задаёт `core` (pydantic-модели), а **значения** в них — `clinical`.
|
`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.<field> = ...` в `app/integrations/setup()`.
|
||||||
|
|
||||||
|
### Направления развития
|
||||||
|
| Направление | Статус | Milestone |
|
||||||
|
|---|---|---|
|
||||||
|
| Ветераны СВО | **фокус хакатона** | `Этап 1 · Ветераны СВО` (+ `M3 Сдача`) |
|
||||||
|
| Семьи ветеранов | опционально, после хакатона | `Будущее · Семьи ветеранов` (эпик #40) |
|
||||||
|
| Депрессия в общей популяции | опционально, после хакатона | `Будущее · Депрессия (общая популяция)` (эпик #43) |
|
||||||
|
|
||||||
|
Новое направление = новый файл `app/dialog/flows/<module>.py` + запись в `config/modules.yaml`; движок, API и дашборд расширяются в своих зонах.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Откуда задачи
|
## Откуда задачи
|
||||||
|
|
||||||
- Только из Gitea `$GITEA_REPO`. Нет issue — нет кода. Нет подходящего issue — заведи его на себя.
|
- Только из 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, связанных зависимостью через API ([`GITEA_API.md`](GITEA_API.md)).
|
||||||
- Тело issue — по шаблону [`ISSUE_TEMPLATE.md`](ISSUE_TEMPLATE.md).
|
- Тело issue — по шаблону [`ISSUE_TEMPLATE.md`](ISSUE_TEMPLATE.md).
|
||||||
|
|
||||||
### Milestones
|
### Milestones
|
||||||
| Milestone | Срок | Смысл |
|
| Milestone | Срок | Смысл |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| M1 Каркас | сб 03.10, 23:59 | ядро, БД, LLM-шлюз, конфиги; тесты зелёные |
|
| M1 Каркас | сб 03.10 ✅ | ядро, БД, LLM-шлюз, конфиги |
|
||||||
| M2 Демо-сценарий | вс 04.10, 23:59 | весь сценарий проходит end-to-end в демо-консоли |
|
| Этап 1 · Ветераны СВО | вс 04.10, 23:59 | демо Андрея end-to-end (эпик #35) |
|
||||||
| M3 Сдача | пн 05.10, 13:00 | README, презентация PDF, репетиция, `dev → main` |
|
| M3 Сдача | пн 05.10, 13:00 | README, презентация PDF, репетиция, `dev → main` |
|
||||||
|
| Будущее · … | без срока | опциональные направления |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -78,7 +93,7 @@ scope = зона. `Closes #N` — только в описании PR.
|
|||||||
Closes #<N>
|
Closes #<N>
|
||||||
|
|
||||||
## Зона
|
## Зона
|
||||||
core | bot | backend | dashboard | devops | clinical | pitch | docs
|
A | B | C | clinical | pitch | docs | contract
|
||||||
|
|
||||||
## Как проверить
|
## Как проверить
|
||||||
шаги/команды
|
шаги/команды
|
||||||
|
|||||||
@@ -2,7 +2,65 @@
|
|||||||
|
|
||||||
Источник для seed-данных и сценария демо. Все данные вымышленные.
|
Источник для 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 с
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Опциональное направление: депрессия в общей популяции (Алексей)
|
||||||
|
|
||||||
|
## Персона Алексея
|
||||||
|
|
||||||
| Поле | Значение |
|
| Поле | Значение |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
+2
-1
@@ -19,6 +19,7 @@ dependencies = [
|
|||||||
"plotly>=5.22",
|
"plotly>=5.22",
|
||||||
"pandas>=2.2",
|
"pandas>=2.2",
|
||||||
"qrcode[pil]>=7.4",
|
"qrcode[pil]>=7.4",
|
||||||
|
"tzdata>=2026.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -37,7 +38,7 @@ build-backend = "hatchling.build"
|
|||||||
packages = ["app"]
|
packages = ["app"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 110
|
line-length = 120
|
||||||
target-version = "py311"
|
target-version = "py311"
|
||||||
extend-exclude = [".claude", "docs"]
|
extend-exclude = [".claude", "docs"]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
+4
-12
@@ -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 def test_identity_roundtrip_and_connect_code(dbs: None) -> None:
|
||||||
async with session.get_identity_session() as s:
|
async with session.get_identity_session() as s:
|
||||||
await identity_repo.upsert_doctor(
|
await identity_repo.upsert_doctor(s, doctor_id="d1", full_name="Доктор Тест", clinic_id="c1", on_call=True)
|
||||||
s, doctor_id="d1", full_name="Доктор Тест", clinic_id="c1", on_call=True
|
|
||||||
)
|
|
||||||
await identity_repo.create_patient(
|
await identity_repo.create_patient(
|
||||||
s,
|
s,
|
||||||
token=TOKEN,
|
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=TOKEN, doctor_id="d1", modules=["veteran", "depression"])
|
||||||
await repo.upsert_profile(s, token=TOKEN2, doctor_id="d2")
|
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.upsert_profile(s, token=TOKEN, next_visit=date(2026, 10, 20))
|
||||||
await repo.add_item_response(
|
await repo.add_item_response(s, token=TOKEN, scale="phq9", item_id="9", score=1, source="button", ts=T0)
|
||||||
s, token=TOKEN, scale="phq9", item_id="9", score=1, source="button", ts=T0
|
|
||||||
)
|
|
||||||
await repo.add_scale_result(
|
await repo.add_scale_result(
|
||||||
s,
|
s,
|
||||||
token=TOKEN,
|
token=TOKEN,
|
||||||
@@ -218,9 +214,7 @@ async def test_dialog_state_messages_clock(dbs: None) -> None:
|
|||||||
async with session.get_clinical_session() as s:
|
async with session.get_clinical_session() as s:
|
||||||
assert await repo.get_dialog_state(s, TOKEN) is None
|
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.set_dialog_state(s, token=TOKEN, state="checkin_mood", data={"step": 1}, now=T0)
|
||||||
await repo.add_message(
|
await repo.add_message(s, token=TOKEN, direction="in", text=ScrubbedText("ок"), ts=T0, fsm_state="idle")
|
||||||
s, token=TOKEN, direction="in", text=ScrubbedText("ок"), ts=T0, fsm_state="idle"
|
|
||||||
)
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
await repo.add_message(s, token=TOKEN, direction="x", text=ScrubbedText("ок"), ts=T0)
|
await repo.add_message(s, token=TOKEN, direction="x", text=ScrubbedText("ок"), ts=T0)
|
||||||
assert await repo.load_clock_offset(s) == 0.0
|
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:
|
async def test_naive_datetime_rejected(dbs: None) -> None:
|
||||||
with pytest.raises(Exception, match="naive datetime"):
|
with pytest.raises(Exception, match="naive datetime"):
|
||||||
async with session.get_clinical_session() as s:
|
async with session.get_clinical_session() as s:
|
||||||
await repo.add_alert(
|
await repo.add_alert(s, token=TOKEN, level=AlertLevel.INFO, code="x", reason="x", ts=datetime(2026, 1, 1))
|
||||||
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:
|
async def test_session_rolls_back_on_error(dbs: None) -> None:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
assert pseudo_id("11223344595") == pseudo_id("11223344595", key=get_settings().pseudo_key)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize("bad", ["", "1234567890", "123456789012", "112-233-445 9X", "11223344595"])
|
||||||
"bad", ["", "1234567890", "123456789012", "112-233-445 9X", "11223344595"]
|
|
||||||
)
|
|
||||||
def test_pseudo_id_rejects_invalid_snils(bad: str) -> None:
|
def test_pseudo_id_rejects_invalid_snils(bad: str) -> None:
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
pseudo_id(bad, key=KEY)
|
pseudo_id(bad, key=KEY)
|
||||||
|
|||||||
+5
-15
@@ -60,9 +60,7 @@ def test_yaml_loads_and_every_rule_has_evaluator() -> None:
|
|||||||
|
|
||||||
def test_unknown_rule_id_rejected() -> None:
|
def test_unknown_rule_id_rejected() -> None:
|
||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
RulesConfig.model_validate(
|
RulesConfig.model_validate({"rules": [{"id": "nope", "actions": [{"type": "alert", "level": "info"}]}]})
|
||||||
{"rules": [{"id": "nope", "actions": [{"type": "alert", "level": "info"}]}]}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_alert_without_level_rejected() -> None:
|
def test_alert_without_level_rejected() -> None:
|
||||||
@@ -134,9 +132,7 @@ def test_cssrs_levels() -> None:
|
|||||||
(None, "moderate", None, False),
|
(None, "moderate", None, False),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_weapon_access_with_risk(
|
def test_weapon_access_with_risk(weapon: bool | None, cssrs: str | None, item9: int | None, fires: bool) -> None:
|
||||||
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)]
|
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)
|
s = snap(5, weapon_access=weapon, cssrs_risk=cssrs, scale_results=scales)
|
||||||
assert ("weapon_access_risk" in codes(evaluate(s), "crisis")) is fires
|
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) == []
|
assert evaluate(s) == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(("sex", "total", "fires"), [("m", 4, True), ("m", 3, False), ("f", 3, True), ("f", 2, False)])
|
||||||
("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:
|
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)])
|
s = snap(1, sex=sex, scale_results=[ScalePoint(scale="auditc", ts=at(0), total=total)])
|
||||||
acts = evaluate(s)
|
acts = evaluate(s)
|
||||||
@@ -212,9 +206,7 @@ def test_quit_intent_voiced() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_quit_intent_followed_by_skipped_dose() -> None:
|
def test_quit_intent_followed_by_skipped_dose() -> None:
|
||||||
s = snap(
|
s = snap(7, checkins=[ci(5, med_taken=True, quit_intent=True), ci(6, med_taken=True), ci(7, med_taken=False)])
|
||||||
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")
|
assert "quit_intent" in codes(evaluate(s), "alert")
|
||||||
no_intent = snap(7, checkins=[ci(6, med_taken=True), ci(7, med_taken=False)])
|
no_intent = snap(7, checkins=[ci(6, med_taken=True), ci(7, med_taken=False)])
|
||||||
assert "quit_intent" not in codes(evaluate(no_intent))
|
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
|
assert evaluate(snap(10, last_patient_message_at=at(7, 21))) == [] # exactly 3 days
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(("age", "day", "fires"), [(23, 8, True), (24, 28, True), (25, 8, False), (23, 29, False)])
|
||||||
("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:
|
def test_young_adult_enhanced_monitoring(age: int, day: int, fires: bool) -> None:
|
||||||
acts = evaluate(snap(day, age=age))
|
acts = evaluate(snap(day, age=age))
|
||||||
assert (acts == [a for a in acts if a.type == "enhanced_monitoring"] and bool(acts)) is fires
|
assert (acts == [a for a in acts if a.type == "enhanced_monitoring"] and bool(acts)) is fires
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def severity(scale_id: str, total: int, **kw: object) -> str:
|
|||||||
|
|
||||||
def test_all_scales_load_with_expected_item_counts() -> None:
|
def test_all_scales_load_with_expected_item_counts() -> None:
|
||||||
counts = {k: len(v.items) for k, v in load_all().items()}
|
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
|
assert counts == expected
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1088,6 +1088,7 @@ dependencies = [
|
|||||||
{ name = "qrcode", extra = ["pil"] },
|
{ name = "qrcode", extra = ["pil"] },
|
||||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||||
{ name = "streamlit" },
|
{ name = "streamlit" },
|
||||||
|
{ name = "tzdata" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1115,6 +1116,7 @@ requires-dist = [
|
|||||||
{ name = "qrcode", extras = ["pil"], specifier = ">=7.4" },
|
{ name = "qrcode", extras = ["pil"], specifier = ">=7.4" },
|
||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
|
||||||
{ name = "streamlit", specifier = ">=1.38" },
|
{ name = "streamlit", specifier = ">=1.38" },
|
||||||
|
{ name = "tzdata", specifier = ">=2026.5" },
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user