chore(core): stage 1 contracts — dialog, API schemas + fixtures, integrations, monitoring, modules
CI / ruff · pytest (pull_request) Successful in 22s

Freeze interfaces between lanes A (bot), B (backend), C (dashboard) so three developers work in
parallel without touching each other's files: dialog Event/Reply + engine registry with echo stub,
integration protocols with in-memory defaults, MonitoringService with noop stub, /api/v1 schemas
with fixture-backed routes, frozen main.py wiring, config/modules.yaml + due_scales(), DAR-5,
tzdata, Andrey trajectory, lanes in WORKFLOW, ADR-010.

Refs #34

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 15:22:25 +03:00
co-authored by Claude Opus 5.5
parent a86008f248
commit b4731fb1d6
30 changed files with 1126 additions and 89 deletions
+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 with session.get_identity_session() as s:
await identity_repo.upsert_doctor(
s, doctor_id="d1", full_name="Доктор Тест", clinic_id="c1", on_call=True
)
await identity_repo.upsert_doctor(s, doctor_id="d1", full_name="Доктор Тест", clinic_id="c1", on_call=True)
await identity_repo.create_patient(
s,
token=TOKEN,
@@ -147,9 +145,7 @@ async def test_scales_profiles_prescriptions(dbs: None) -> None:
await repo.upsert_profile(s, token=TOKEN, doctor_id="d1", modules=["veteran", "depression"])
await repo.upsert_profile(s, token=TOKEN2, doctor_id="d2")
await repo.upsert_profile(s, token=TOKEN, next_visit=date(2026, 10, 20))
await repo.add_item_response(
s, token=TOKEN, scale="phq9", item_id="9", score=1, source="button", ts=T0
)
await repo.add_item_response(s, token=TOKEN, scale="phq9", item_id="9", score=1, source="button", ts=T0)
await repo.add_scale_result(
s,
token=TOKEN,
@@ -218,9 +214,7 @@ async def test_dialog_state_messages_clock(dbs: None) -> None:
async with session.get_clinical_session() as s:
assert await repo.get_dialog_state(s, TOKEN) is None
await repo.set_dialog_state(s, token=TOKEN, state="checkin_mood", data={"step": 1}, now=T0)
await repo.add_message(
s, token=TOKEN, direction="in", text=ScrubbedText("ок"), ts=T0, fsm_state="idle"
)
await repo.add_message(s, token=TOKEN, direction="in", text=ScrubbedText("ок"), ts=T0, fsm_state="idle")
with pytest.raises(ValueError):
await repo.add_message(s, token=TOKEN, direction="x", text=ScrubbedText("ок"), ts=T0)
assert await repo.load_clock_offset(s) == 0.0
@@ -237,9 +231,7 @@ async def test_dialog_state_messages_clock(dbs: None) -> None:
async def test_naive_datetime_rejected(dbs: None) -> None:
with pytest.raises(Exception, match="naive datetime"):
async with session.get_clinical_session() as s:
await repo.add_alert(
s, token=TOKEN, level=AlertLevel.INFO, code="x", reason="x", ts=datetime(2026, 1, 1)
)
await repo.add_alert(s, token=TOKEN, level=AlertLevel.INFO, code="x", reason="x", ts=datetime(2026, 1, 1))
async def test_session_rolls_back_on_error(dbs: None) -> None:
+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)
@pytest.mark.parametrize(
"bad", ["", "1234567890", "123456789012", "112-233-445 9X", "11223344595"]
)
@pytest.mark.parametrize("bad", ["", "1234567890", "123456789012", "112-233-445 9X", "11223344595"])
def test_pseudo_id_rejects_invalid_snils(bad: str) -> None:
with pytest.raises(ValueError):
pseudo_id(bad, key=KEY)
+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:
with pytest.raises(ValidationError):
RulesConfig.model_validate(
{"rules": [{"id": "nope", "actions": [{"type": "alert", "level": "info"}]}]}
)
RulesConfig.model_validate({"rules": [{"id": "nope", "actions": [{"type": "alert", "level": "info"}]}]})
def test_alert_without_level_rejected() -> None:
@@ -134,9 +132,7 @@ def test_cssrs_levels() -> None:
(None, "moderate", None, False),
],
)
def test_weapon_access_with_risk(
weapon: bool | None, cssrs: str | None, item9: int | None, fires: bool
) -> None:
def test_weapon_access_with_risk(weapon: bool | None, cssrs: str | None, item9: int | None, fires: bool) -> None:
scales = [] if item9 is None else [ScalePoint(scale="phq9", ts=at(5, 20), total=10, item9=item9)]
s = snap(5, weapon_access=weapon, cssrs_risk=cssrs, scale_results=scales)
assert ("weapon_access_risk" in codes(evaluate(s), "crisis")) is fires
@@ -188,9 +184,7 @@ def test_future_scale_results_ignored() -> None:
assert evaluate(s) == []
@pytest.mark.parametrize(
("sex", "total", "fires"), [("m", 4, True), ("m", 3, False), ("f", 3, True), ("f", 2, False)]
)
@pytest.mark.parametrize(("sex", "total", "fires"), [("m", 4, True), ("m", 3, False), ("f", 3, True), ("f", 2, False)])
def test_auditc_by_sex(sex: str, total: int, fires: bool) -> None:
s = snap(1, sex=sex, scale_results=[ScalePoint(scale="auditc", ts=at(0), total=total)])
acts = evaluate(s)
@@ -212,9 +206,7 @@ def test_quit_intent_voiced() -> None:
def test_quit_intent_followed_by_skipped_dose() -> None:
s = snap(
7, checkins=[ci(5, med_taken=True, quit_intent=True), ci(6, med_taken=True), ci(7, med_taken=False)]
)
s = snap(7, checkins=[ci(5, med_taken=True, quit_intent=True), ci(6, med_taken=True), ci(7, med_taken=False)])
assert "quit_intent" in codes(evaluate(s), "alert")
no_intent = snap(7, checkins=[ci(6, med_taken=True), ci(7, med_taken=False)])
assert "quit_intent" not in codes(evaluate(no_intent))
@@ -303,9 +295,7 @@ def test_silence_over_3_days() -> None:
assert evaluate(snap(10, last_patient_message_at=at(7, 21))) == [] # exactly 3 days
@pytest.mark.parametrize(
("age", "day", "fires"), [(23, 8, True), (24, 28, True), (25, 8, False), (23, 29, False)]
)
@pytest.mark.parametrize(("age", "day", "fires"), [(23, 8, True), (24, 28, True), (25, 8, False), (23, 29, False)])
def test_young_adult_enhanced_monitoring(age: int, day: int, fires: bool) -> None:
acts = evaluate(snap(day, age=age))
assert (acts == [a for a in acts if a.type == "enhanced_monitoring"] and bool(acts)) is fires
+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:
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