release: dev → main (сдача хакатона) #106

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