Merge origin/dev into feat/rag-clinical-guidelines
CI / ruff · pytest (pull_request) Successful in 2m9s

Conflict in app/api/routes.py: /demo/personas keeps the dev version (demo console, DB patients
with scenarios); the patient app gets its own /patient/demo-personas (synthetic ЕМИАС accounts).
Anonymous sign-in now always creates a doctor-less pseudonymous profile, since the dialog API
of #12 serves known profiles only.

Refs #77

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 15:22:29 +03:00
co-authored by Claude Opus 5.5
82 changed files with 9945 additions and 153 deletions
+17
View File
@@ -0,0 +1,17 @@
"""Test-wide isolation: a developer's local `.env` (e.g. OPENROUTER_API_KEY) must never make tests call a real
LLM provider — that costs money and makes results depend on the network. Agent tests pass explicit
providers/settings; everything else runs with the chat agent off."""
import pytest
from app.config import get_settings
@pytest.fixture(autouse=True)
def _no_live_llm(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setenv("CHAT_AGENT", "off")
monkeypatch.setenv("OPENROUTER_API_KEY", "")
monkeypatch.setenv("LLM_PROVIDER", "mock")
get_settings.cache_clear()
yield
get_settings.cache_clear()
+157
View File
@@ -0,0 +1,157 @@
"""API #12: dialog endpoints (events, outbox, history) and demo controls (clock, reset, personas)."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from pathlib import Path
import httpx
import pytest
from app.api import demo
from app.config import Settings
from app.core import clock
from app.core.types import ScrubbedText
from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.dialog.contracts import Reply
from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations
from app.main import app
MEM = "sqlite+aiosqlite:///:memory:"
TOKEN = "a" * 32
DAY0 = datetime(2026, 10, 5, 9, 0)
@pytest.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
monkeypatch.setattr(integrations, "notifier", MemoryNotifier())
monkeypatch.setattr(integrations, "outbox", MemoryOutbox())
clock.set_now(DAY0)
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with session.get_identity_session() as ids:
await identity_repo.create_patient(
ids,
token=TOKEN,
display_name="Андрей С.",
snils="11122233344",
phone="+79990000001",
clinic_id="c",
doctor_id="doc-smirnova",
now=clock.now(),
birth_year=1992,
)
async with session.get_clinical_session() as s:
await repo.upsert_profile(
s,
token=TOKEN,
doctor_id="doc-smirnova",
modules=["veteran"],
therapy_start=clock.today() - timedelta(days=13),
)
await repo.set_dialog_state(
s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=clock.now()
)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
yield c
clock.register_persistence(None, None)
clock.reset()
await session.configure()
async def test_events_reach_engine_and_unknown_patient_is_404(client: httpx.AsyncClient) -> None:
r = await client.post(f"/api/v1/dialog/{TOKEN}/events", json={"type": "text", "text": "Привет"})
assert r.status_code == 200 and r.json()["replies"]
r = await client.post("/api/v1/dialog/" + "f" * 32 + "/events", json={"type": "text", "text": "x"})
assert r.status_code == 404
async def test_outbox_drains_once(client: httpx.AsyncClient) -> None:
await integrations.outbox.push(TOKEN, [Reply(text="Пора принять лекарство")])
first = (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"]
assert [x["text"] for x in first] == ["Пора принять лекарство"]
assert (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"] == []
async def test_outbox_found_behind_wrapping_outbox(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
inner = MemoryOutbox()
class Wrapper: # like TelegramOutbox: unbound chats fall back to the console queue
fallback = inner
async def push(self, token: str, replies: list[Reply]) -> None:
await inner.push(token, replies)
monkeypatch.setattr(integrations, "outbox", Wrapper())
await inner.push(TOKEN, [Reply(text="x")])
assert len((await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"]) == 1
async def test_history_oldest_first_with_limit(client: httpx.AsyncClient) -> None:
async with session.get_clinical_session() as s:
for i in range(5):
await repo.add_message(
s,
token=TOKEN,
direction="in" if i % 2 == 0 else "out",
text=ScrubbedText(f"m{i}"),
ts=clock.now() + timedelta(minutes=i),
)
rows = (await client.get(f"/api/v1/dialog/{TOKEN}/history", params={"limit": 3})).json()
assert [m["text"] for m in rows] == ["m2", "m3", "m4"]
assert [m["direction"] for m in rows] == ["in", "out", "in"]
async def test_history_stores_scrubbed_text(client: httpx.AsyncClient) -> None:
await client.post(f"/api/v1/dialog/{TOKEN}/events", json={"type": "text", "text": "мой телефон +7 999 123-45-67"})
texts = " ".join(m["text"] for m in (await client.get(f"/api/v1/dialog/{TOKEN}/history")).json())
assert "123-45-67" not in texts and "999" not in texts
async def test_clock_advance_and_reset(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(demo, "SEED_MODULE", "scripts.no_such_seed_module")
before = (await client.get("/api/v1/demo/clock")).json()
r = (await client.post("/api/v1/demo/clock/advance", json={"hours": 2})).json()
assert r["virtual"] is True and r["offset_days"] == pytest.approx(before["offset_days"] + 2 / 24, abs=1e-3)
await integrations.outbox.push(TOKEN, [Reply(text="stale")])
assert (await client.post("/api/v1/demo/reset")).json() == {"status": "ok", "seed": "skipped"}
assert (await client.get("/api/v1/demo/clock")).json()["offset_days"] == pytest.approx(0, abs=1e-6)
assert (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"] == []
async def test_reset_calls_seed_module(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
import sys
import types
calls: list[str] = []
fake = types.ModuleType("fake_seed_demo")
async def reset() -> None:
calls.append("reset")
fake.reset = reset # type: ignore[attr-defined]
monkeypatch.setitem(sys.modules, "fake_seed_demo", fake)
monkeypatch.setattr(demo, "SEED_MODULE", "fake_seed_demo")
monkeypatch.setattr(demo.util, "find_spec", lambda name: object())
assert (await client.post("/api/v1/demo/reset")).json()["seed"] == "reset"
assert calls == ["reset"]
async def test_personas_from_scenario_files(
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setattr(demo, "get_settings", lambda: Settings(_env_file=None, data_dir=tmp_path))
assert (await client.get("/api/v1/demo/personas")).json() == []
(tmp_path / "synthetic").mkdir()
(tmp_path / "synthetic" / "andrey.jsonl").write_text('{"day": 0, "actions": []}\n', encoding="utf-8")
personas = (await client.get("/api/v1/demo/personas")).json()
assert personas == [
{"token": TOKEN, "display_name": "Андрей С.", "therapy_day": 13, "scenario": "data/synthetic/andrey.jsonl"}
]
def test_persona_slug() -> None:
assert demo.persona_slug("Андрей С.") == "andrey"
assert demo.persona_slug("Юлия Щ.") == "yuliya"
assert demo.persona_slug("") == ""
+288
View File
@@ -0,0 +1,288 @@
"""API #12: doctor dashboard on real DB data — list, card + audit, 403, alerts, ack → crisis ends, connect, no PII."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from types import SimpleNamespace
import httpx
import pytest
from sqlalchemy import select
from app.api import doctor_views
from app.config import Settings
from app.core import clock
from app.core.privacy import pseudo_id
from app.core.types import AlertLevel
from app.db import identity_repo, repo, session
from app.db.audit import AuditLog
from app.db.bootstrap import init_db
from app.db.clinical import Dose, PatientProfile, Prescription, SafetyPlan
from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations
from app.main import app
MEM = "sqlite+aiosqlite:///:memory:"
DOCTOR = "doc-smirnova"
OTHER = "doc-other"
DAY0 = datetime(2026, 10, 5, 9, 0)
# Synthetic patients: token suffix → (name, snils, phone, doctor)
ANDREY, DMITRY, OLGA, PAVEL, IRINA, STRANGER = (c * 32 for c in "abcdef")
PEOPLE = {
ANDREY: ("Андрей С.", "11122233344", "+79990000001", DOCTOR),
DMITRY: ("Дмитрий В.", "22233344455", "+79990000002", DOCTOR),
OLGA: ("Ольга К.", "33344455566", "+79990000003", DOCTOR),
PAVEL: ("Павел Н.", "44455566677", "+79990000004", DOCTOR),
IRINA: ("Ирина М.", "55566677788", "+79990000005", DOCTOR),
STRANGER: ("Чужой П.", "66677788899", "+79990000006", OTHER),
}
@pytest.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
monkeypatch.setattr(integrations, "notifier", MemoryNotifier())
monkeypatch.setattr(integrations, "outbox", MemoryOutbox())
clock.set_now(DAY0 + timedelta(days=13))
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
await seed()
# No lifespan: the scheduler and the Telegram bot are not started.
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
yield c
clock.register_persistence(None, None)
clock.reset()
await session.configure()
async def seed() -> None:
now = clock.now()
start = (DAY0).date()
async with session.get_identity_session() as ids:
for token, (name, snils, phone, doctor) in PEOPLE.items():
await identity_repo.create_patient(
ids,
token=token,
display_name=name,
snils=snils,
phone=phone,
clinic_id="clinic-1",
doctor_id=doctor,
now=now,
birth_year=1990,
sex="m",
)
async with session.get_clinical_session() as s:
for token, (_, _, _, doctor) in PEOPLE.items():
await repo.upsert_profile(
s, token=token, doctor_id=doctor, modules=["veteran"], therapy_start=start, birth_year=1990, sex="m"
)
s.add(Prescription(token=token, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}))
await s.flush()
rx = {r.token: r.id for r in (await s.scalars(select(Prescription))).all()}
# Andrey: acute alert (red), worsening PHQ-9, unscheduled PHQ-9, low mood streak, alcohol, a safety plan.
await repo.add_alert(s, token=ANDREY, level=AlertLevel.ACUTE, code="phq9_item9", reason="PHQ-9 п.9", ts=now)
await repo.add_scale_result(
s, token=ANDREY, scale="phq9", total=12, severity="moderate", complete=True, ts=now - timedelta(days=7)
)
await repo.add_scale_result(
s,
token=ANDREY,
scale="phq9",
total=18,
severity="mod_severe",
complete=True,
ts=now - timedelta(hours=2),
scheduled=False,
item9=2,
)
await repo.add_scale_result(
s, token=ANDREY, scale="pcl5", total=44, severity="-", complete=True, ts=now - timedelta(days=13)
)
for i, (mood, sleep, alcohol) in enumerate([(3, "<4", "more"), (2, "4-6", "1-2"), (2, "<4", "none")]):
await repo.upsert_checkin(
s,
token=ANDREY,
day=now.date() - timedelta(days=2 - i),
now=now,
mood=mood,
sleep_bucket=sleep,
alcohol_bucket=alcohol,
med_taken=True,
)
s.add(
SafetyPlan(
token=ANDREY, steps=["Позвонить брату", {"who": "Кризисная линия", "phone": "8-800"}], updated_at=now
)
)
# Dmitry: high alert (yellow). Olga: info only (grey).
await repo.add_alert(s, token=DMITRY, level=AlertLevel.HIGH, code="pcl5_high", reason="PCL-5 ≥ 50", ts=now)
await repo.add_alert(s, token=OLGA, level=AlertLevel.INFO, code="no_contact_3d", reason="Молчит 3 дня", ts=now)
# Pavel and Irina: green; Pavel missed doses + voiced quit intent → higher risk, sorted first among greens.
for d in range(7):
planned = now - timedelta(days=d, hours=1)
s.add(
Dose(token=PAVEL, prescription_id=rx[PAVEL], planned_at=planned, status="missed" if d < 3 else "taken")
)
s.add(Dose(token=IRINA, prescription_id=rx[IRINA], planned_at=planned, status="taken"))
await repo.upsert_checkin(s, token=PAVEL, day=now.date(), now=now, quit_intent=True, mood=5)
# Foreign doctor's patient with an open alert: must never show up for DOCTOR.
await repo.add_alert(s, token=STRANGER, level=AlertLevel.ACUTE, code="cssrs_high", reason="C-SSRS", ts=now)
await repo.set_dialog_state(
s,
token=ANDREY,
state="crisis",
data={"consent": True, "adult": True, "crisis": {"open": True, "opened_at": now.isoformat()}},
now=now,
)
async def audit_rows(action: str) -> list[AuditLog]:
async with session.get_clinical_session() as s:
return list((await s.scalars(select(AuditLog).where(AuditLog.action == action))).all())
async def test_list_order_colours_and_metrics(client: httpx.AsyncClient) -> None:
rows = (await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).json()
assert [r["token"] for r in rows] == [ANDREY, DMITRY, OLGA, PAVEL, IRINA]
assert [r["status_color"] for r in rows] == ["red", "yellow", "grey", "green", "green"]
andrey, pavel, irina = rows[0], rows[3], rows[4]
assert andrey["display_name"] == "Андрей С." and andrey["age"] == 36
assert andrey["top_alert_level"] == "acute" and andrey["open_alerts"] == 1
assert (andrey["phq9_last"], andrey["pcl5_last"], andrey["trend"]) == (18, 44, "up")
assert andrey["drug"] == "сертралин 50 мг" and andrey["therapy_day"] == 13
assert andrey["last_event"] == "PHQ-9 п.9" # the alert is newer than the last scale
assert rows[1]["last_event"] == "PCL-5 ≥ 50"
factors = " ".join(andrey["discontinuation_risk"]["factors"])
assert "настроение ≤ 3 3 дн. подряд" in factors and "алкоголь 2 дн. из 7" in factors
assert pavel["adherence_7d"] == pytest.approx(4 / 7, abs=0.01)
assert irina["adherence_7d"] == 1.0 and irina["discontinuation_risk"]["score"] == 0
assert pavel["discontinuation_risk"]["score"] == 30 + 25 # 3 missed days + quit intent
assert pavel["discontinuation_risk"]["level"] == "medium"
assert len(await audit_rows("view_patient_list")) == 1
async def test_card_writes_audit_and_has_series(client: httpx.AsyncClient) -> None:
r = await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": DOCTOR})
assert r.status_code == 200
card = r.json()
metrics = {(p["metric"], p["value"], p["scheduled"]) for p in card["series"]}
assert ("phq9", 18.0, False) in metrics and ("phq9", 12.0, True) in metrics
assert ("sleep_hours", 3.0, True) in metrics and ("sleep_hours", 5.0, True) in metrics
assert ("alcohol", 2.0, True) in metrics and ("mood", 2.0, True) in metrics
assert card["safety_plan"] == ["Позвонить брату", "Кризисная линия: 8-800"]
assert card["alerts"][0]["patient_name"] == "Андрей С." and card["alerts"][0]["color"] == "red"
assert card["prescriptions"][0]["schedule"] == {"times": ["08:00"]}
assert card["summary"]
rows = await audit_rows("view_patient")
assert len(rows) == 1 and rows[0].actor == DOCTOR and rows[0].token == ANDREY
async def test_card_forbidden_for_foreign_doctor(client: httpx.AsyncClient) -> None:
r = await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": OTHER})
assert r.status_code == 403
assert await audit_rows("view_patient") == []
assert len(await audit_rows("view_patient_denied")) == 1
assert (await client.get("/api/v1/patients/" + "0" * 32, params={"doctor_id": DOCTOR})).status_code == 404
async def test_alerts_feed_since_id_and_open_only(client: httpx.AsyncClient) -> None:
alerts = (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts")).json()
assert {a["token"] for a in alerts} == {ANDREY, DMITRY, OLGA} # no foreign patient
assert all(a["patient_name"] for a in alerts)
top = max(a["id"] for a in alerts)
assert (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts", params={"since_id": top})).json() == []
assert len(await audit_rows("view_alerts")) == 1 # empty poll is not a disclosure
async def test_ack_acute_ends_crisis(client: httpx.AsyncClient) -> None:
alerts = (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts", params={"open_only": True})).json()
acute = next(a for a in alerts if a["token"] == ANDREY)
assert (await client.post(f"/api/v1/alerts/{acute['id']}/ack", json={"doctor_id": OTHER})).status_code == 403
r = await client.post(f"/api/v1/alerts/{acute['id']}/ack", json={"doctor_id": DOCTOR})
assert r.status_code == 200
out = r.json()
assert out["acked_by"] == DOCTOR and out["acked_at"] and out["patient_name"] == "Андрей С."
async with session.get_clinical_session() as s:
state = await repo.get_dialog_state(s, ANDREY)
profile = await s.get(PatientProfile, ANDREY)
assert state is not None and state.state == "idle" and state.data["crisis"]["open"] is False
assert profile is not None and profile.status_color == "green"
assert isinstance(integrations.outbox, MemoryOutbox)
assert integrations.outbox.drain(ANDREY) # "crisis closed" message waits for the console
assert len(await audit_rows("ack_alert")) == 1
rows = (await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).json()
assert next(x for x in rows if x["token"] == ANDREY)["status_color"] == "green"
async def test_connect_creates_patient_and_redeemable_code(client: httpx.AsyncClient) -> None:
body = {
"doctor_id": DOCTOR,
"display_name": "Сергей Т.",
"birth_year": 1988,
"sex": "m",
"snils": "123-456-789 01",
"drug": "сертралин",
"dose": "50 мг",
"times": ["08:00", "20:00"],
"instructions": "после еды",
"modules": ["veteran", "depression"],
}
r = await client.post("/api/v1/patients/connect", json=body)
assert r.status_code == 200
out = r.json()
assert out["token"] == pseudo_id("12345678901")
assert out["deep_link"] == f"/console?code={out['code']}"
assert "12345678901" not in r.text and "123-456-789" not in r.text
async with session.get_identity_session() as ids:
assert await identity_repo.redeem_connect_code(ids, code=out["code"], now=clock.now()) == out["token"]
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, out["token"])
rx = await s.scalar(select(Prescription).where(Prescription.token == out["token"]))
assert profile is not None and profile.therapy_start == clock.today() and profile.doctor_id == DOCTOR
assert rx is not None and rx.schedule["times"] == ["08:00", "20:00"]
assert len(await audit_rows("connect_patient")) == 1
card = (await client.get(f"/api/v1/patients/{out['token']}", params={"doctor_id": DOCTOR})).json()
assert card["prescriptions"][0]["instructions"] == "после еды"
assert card["patient"]["therapy_day"] == 0 and card["patient"]["status_color"] == "green"
assert (await client.post("/api/v1/patients/connect", json=body)).status_code == 409
async def test_no_pii_in_doctor_responses(client: httpx.AsyncClient) -> None:
texts = [
(await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).text,
(await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": DOCTOR})).text,
(await client.get(f"/api/v1/doctors/{DOCTOR}/alerts")).text,
]
for _, snils, phone, _ in PEOPLE.values():
for text in texts:
assert snils not in text and phone not in text and phone[1:] not in text
def test_risk_index_all_factors_capped() -> None:
now = datetime(2026, 10, 18, 12, 0).astimezone()
days = [now.date() - timedelta(days=i) for i in range(4)]
checkins = [
SimpleNamespace(
day=d, mood=2, med_taken=False, side_effects=["nausea"], quit_intent=True, alcohol_bucket="more"
)
for d in days
]
data = doctor_views.PatientData(
profile=SimpleNamespace(therapy_start=days[-1] - timedelta(days=10)), # type: ignore[arg-type]
checkins=checkins, # type: ignore[arg-type]
results=[],
doses=[],
prescriptions=[],
alerts=[SimpleNamespace(code="quit_intent", acked_at=None)], # type: ignore[list-item]
)
risk = doctor_views.discontinuation_risk(data, now)
assert risk.score == 100 and risk.level == "high"
assert risk.factors == [
"пропуски приёма 4/7 дн.",
"побочные эффекты 4 дн. подряд",
"хотел бросить (день 13)",
"не разобран сигнал «хочет бросить»",
"настроение ≤ 3 4 дн. подряд",
"алкоголь 4 дн. из 7",
]
+12 -5
View File
@@ -40,7 +40,7 @@ async def _patient_with_code(code: str) -> None:
await identity_repo.create_connect_code(s, code=code, token=TOKEN, doctor_id="doc-1", now=clock.now())
async def test_anonymous_tokens_are_fresh_and_store_nothing(client: AsyncClient) -> None:
async def test_anonymous_tokens_are_fresh_and_store_no_personal_data(client: AsyncClient) -> None:
first = (await client.post("/api/v1/auth/anonymous")).json()
second = (await client.post("/api/v1/auth/anonymous")).json()
assert first["mode"] == "anonymous" and first["display_name"] is None
@@ -48,7 +48,9 @@ async def test_anonymous_tokens_are_fresh_and_store_nothing(client: AsyncClient)
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, first["token"]) is None
async with session.get_clinical_session() as s:
assert await repo.get_profile(s, first["token"]) is None
# Only a doctor-less pseudonymous profile: the dialog API serves known profiles only.
profile = await repo.get_profile(s, first["token"])
assert profile.doctor_id == "" and profile.modules == []
async def test_anonymous_focus_picks_the_module_without_a_doctor(client: AsyncClient) -> None:
@@ -199,10 +201,10 @@ async def test_meds_reference_is_sourced_and_hides_rule_params(client: AsyncClie
assert all(ind["source"] for d in body["drugs"] for ind in d["indications"].values())
async def test_stage_one_starts_without_a_profile(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
# An anonymous token unknown to this database (e.g. after a reset) has no profile and no modules.
async def test_stage_one_starts_without_modules(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
# Stage 1 is the same for everyone: it does not wait for a module (it sets them itself).
monkeypatch.setenv("INTAKE_DEMO", "1")
token = "c" * 32
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
async def press(button: str | None = None) -> list[str]:
event = {"type": "button", "button": button} if button else {"type": "start"}
@@ -213,3 +215,8 @@ async def test_stage_one_starts_without_a_profile(client: AsyncClient, monkeypat
await press()
await press("consent:yes")
assert any(":phq9_1:" in b for b in await press("age:adult"))
async def test_patient_app_has_its_own_demo_personas(client: AsyncClient) -> None:
body = (await client.get("/api/v1/patient/demo-personas")).json()
assert [p["token"] for p in body] == [p.token for p in fixtures.DEMO_PERSONAS]
+5 -6
View File
@@ -21,11 +21,10 @@ async def test_stubs_are_usable() -> None:
await session.configure()
def test_api_fixture_routes() -> None:
def test_api_fixtures_and_clock() -> None:
# Routes read the DB since #12 (covered by tests/test_api_*.py); fixtures stay as schema-valid reference data.
from app.api import fixtures
assert fixtures.card("tok-andrey") is not None and fixtures.PATIENTS[0].status_color == "red"
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
+126
View File
@@ -0,0 +1,126 @@
"""Dashboard HTTP client: paths, parsing via the frozen schemas, graceful errors (no tracebacks to the UI)."""
import json
import httpx
import pytest
from app.api import fixtures
from app.api.schemas import ConnectPatientIn
from dashboard.api_client import ApiClient, ApiError, ApiUnavailable
def _client(handler) -> ApiClient:
return ApiClient("http://api.test", "doc-smirnova", transport=httpx.MockTransport(handler))
def _json(models) -> list | dict:
if isinstance(models, list):
return [m.model_dump(mode="json") for m in models]
return models.model_dump(mode="json")
def test_base_url_gets_api_prefix_once():
assert ApiClient("http://x:8000/").base_url == "http://x:8000/api/v1"
assert ApiClient("http://x:8000/api/v1").base_url == "http://x:8000/api/v1"
def test_doctor_id_from_env(monkeypatch):
monkeypatch.setenv("DOCTOR_ID", "doc-other")
assert ApiClient("http://x").doctor_id == "doc-other"
monkeypatch.delenv("DOCTOR_ID")
assert ApiClient("http://x").doctor_id == "doc-smirnova"
def test_patients_and_card_are_parsed():
seen: list[str] = []
def handler(req: httpx.Request) -> httpx.Response:
seen.append(str(req.url))
if req.url.path.endswith("/patients"):
return httpx.Response(200, json=_json(fixtures.PATIENTS))
return httpx.Response(200, json=_json(fixtures.andrey_card()))
c = _client(handler)
patients = c.patients()
assert patients[0].display_name == "Андрей С."
card = c.patient_card("tok-andrey")
assert card.patient.status_color == "red"
assert seen[0] == "http://api.test/api/v1/doctors/doc-smirnova/patients"
assert seen[1] == "http://api.test/api/v1/patients/tok-andrey?doctor_id=doc-smirnova"
def test_alerts_params_and_ack_body():
captured: dict = {}
def handler(req: httpx.Request) -> httpx.Response:
if req.method == "POST":
captured["body"] = json.loads(req.content)
return httpx.Response(200, json=_json(fixtures.all_alerts()[0]))
captured["params"] = dict(req.url.params)
return httpx.Response(200, json=_json(fixtures.all_alerts()))
c = _client(handler)
assert len(c.alerts(since_id=2, open_only=True)) == 4
assert captured["params"] == {"since_id": "2", "open_only": "true"}
c.ack(3)
assert captured["body"] == {"doctor_id": "doc-smirnova"}
def test_connect_posts_payload():
def handler(req: httpx.Request) -> httpx.Response:
body = json.loads(req.content)
assert body["drug"] == "сертралин" and body["times"] == ["08:00"]
return httpx.Response(200, json={"token": "t", "code": "DEMO-1", "deep_link": "https://t.me/x?start=DEMO-1"})
body = ConnectPatientIn(
doctor_id="doc-smirnova",
display_name="Андрей С.",
birth_year=1992,
sex="m",
snils="000-000-000 00",
drug="сертралин",
dose="50 мг",
)
assert _client(handler).connect(body).code == "DEMO-1"
def test_connection_error_becomes_unavailable():
def handler(req: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused", request=req)
with pytest.raises(ApiUnavailable) as exc:
_client(handler).patients()
assert "API недоступен" in exc.value.message
def test_timeout_becomes_unavailable():
def handler(req: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("slow", request=req)
with pytest.raises(ApiUnavailable):
_client(handler).alerts()
@pytest.mark.parametrize(("status", "fragment"), [(404, "Не найдено"), (500, "500"), (422, "422")])
def test_http_errors(status, fragment):
c = _client(lambda req: httpx.Response(status, json={"detail": "x"}))
with pytest.raises(ApiError) as exc:
c.patient_card("tok-x")
assert fragment in exc.value.message
assert exc.value.status == status
assert not isinstance(exc.value, ApiUnavailable)
def test_bad_payload_is_api_error():
c = _client(lambda req: httpx.Response(200, json=[{"token": "only"}]))
with pytest.raises(ApiError, match="неожиданный"):
c.patients()
c = _client(lambda req: httpx.Response(200, content=b"<html>"))
with pytest.raises(ApiError):
c.patients()
def test_clock_failure_is_none():
c = _client(lambda req: httpx.Response(503))
assert c.clock() is None
+419
View File
@@ -0,0 +1,419 @@
"""Demo console (#17): scenario parser, press resolver, clock math, playback runner, API client."""
import json
import sys
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from typing import Any
import httpx
import pytest
# Streamlit runs pages with `dashboard/` on sys.path and they import `console`; tests mirror that.
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dashboard"))
from console.api import ApiError, ConsoleApi, personas_or_fallback # noqa: E402
from console.chatlog import ChatLog, normalize_reply # noqa: E402
from console.playback import ( # noqa: E402
PlaybackRunner,
PlaybackState,
hours_until,
parse_clock_now,
planned_date,
resolve_press,
target_datetime,
)
from console.scenario import ( # noqa: E402
Action,
default_scenario_path,
load_scenario,
parse_action,
parse_scenario,
persona_slug,
)
MSK = timezone(timedelta(hours=3))
def _jsonl(*rows: Any) -> str:
return "\n".join(r if isinstance(r, str) else json.dumps(r, ensure_ascii=False) for r in rows)
def _reply(text: str, *labels_data: tuple[str, str], kind: str = "normal") -> dict[str, Any]:
return {"text": text, "kind": kind, "buttons": [[{"label": lab, "data": d} for lab, d in labels_data]]}
# ------------------------------------------------------------------------------------------------ scenario
def test_parse_scenario_valid_sorted_and_normalized():
sc = parse_scenario(
_jsonl(
{"day": 2, "note": "тошнота", "actions": [{"advance_to": "8:00"}, {"press": "Принял"}]},
"",
"# comment",
{"day": 0, "actions": [{"start": None}, {"text": "привет"}, {"expect_color": "GREEN"}]},
)
)
assert sc.errors == []
assert sc.day_numbers == [0, 2]
assert sc.day(2).actions == (Action("advance_to", "08:00"), Action("press", "Принял"))
assert sc.day(0).actions[0] == Action("start", "")
assert sc.day(0).actions[2] == Action("expect_color", "green")
assert sc.day(2).note == "тошнота"
def test_parse_scenario_reports_and_skips_bad_parts():
sc = parse_scenario(
_jsonl(
"{not json",
[1, 2],
{"day": -1, "actions": []},
{"day": 1, "actions": [{"advance_to": "25:00"}, {"press": "Да"}, {"fly": "x"}, {"a": 1, "b": 2}]},
{"day": 1, "actions": [{"text": "дубль"}]},
{"day": 3, "actions": "oops"},
{"day": 4, "actions": [{"expect_color": "purple"}, {"text": " "}]},
)
)
assert sc.day_numbers == [1, 4]
assert sc.day(1).actions == (Action("press", "Да"),)
assert sc.day(4).actions == ()
assert len(sc.errors) == 10
assert any("строка 1" in e and "не JSON" in e for e in sc.errors)
assert any("повторяется" in e for e in sc.errors)
assert any("ЧЧ:ММ" in e for e in sc.errors)
@pytest.mark.parametrize("raw", [{"press": 5}, "press", {"advance_to": "8"}, {}])
def test_parse_action_rejects(raw):
with pytest.raises(ValueError):
parse_action(raw)
def test_load_scenario_missing_and_present(tmp_path):
missing = load_scenario("nope.jsonl", root=tmp_path)
assert missing.days == [] and "не найден" in missing.errors[0]
(tmp_path / "s.jsonl").write_text(_jsonl({"day": 5, "actions": [{"text": "брошу"}]}), encoding="utf-8")
assert load_scenario("s.jsonl", root=tmp_path).day_numbers == [5]
# ------------------------------------------------------------------------------------------------ press resolver
def test_resolve_press_newest_reply_first_and_exact_beats_substring():
replies = [
_reply("вчера", ("Принял", "med:taken:1")),
_reply("сегодня", ("Принял позже", "med:later:2"), ("Принял", "med:taken:2")),
]
assert resolve_press(replies, "принял") == ("med:taken:2", "Принял")
assert resolve_press(replies, "позже") == ("med:later:2", "Принял позже")
def test_resolve_press_window_normalization_and_miss():
old = _reply("старое", ("Ещё вопрос", "q:1"))
filler = [_reply(f"r{i}") for i in range(10)]
assert resolve_press([old, *filler], "еще") is None # outside the 10-reply window
assert resolve_press([old, *filler[:3]], " ЕЩЕ вопрос ") == ("q:1", "Ещё вопрос")
assert resolve_press([old], "нет такой") is None
assert resolve_press([old], "") is None
# ------------------------------------------------------------------------------------------------ clock math
def test_target_datetime_next_occurrence_without_planned_date():
now = datetime(2026, 10, 5, 7, 0, tzinfo=MSK)
assert target_datetime(now, "08:00", None) == datetime(2026, 10, 5, 8, 0, tzinfo=MSK)
late = datetime(2026, 10, 5, 21, 0, tzinfo=MSK)
assert target_datetime(late, "08:00", None) == datetime(2026, 10, 6, 8, 0, tzinfo=MSK)
# Just reached (virtual time ticked a bit past): stays today, no rollover.
just = datetime(2026, 10, 5, 8, 2, tzinfo=MSK)
assert target_datetime(just, "08:00", None) == datetime(2026, 10, 5, 8, 0, tzinfo=MSK)
def test_target_datetime_with_planned_date_and_hours_until():
now = datetime(2026, 10, 5, 20, 30, tzinfo=MSK)
target = target_datetime(now, "08:00", date(2026, 10, 6))
assert hours_until(now, target) == pytest.approx(11.5)
assert hours_until(now, now - timedelta(minutes=3)) == 0.0
assert hours_until(now, now - timedelta(hours=2)) is None
def test_planned_date_chains_days_and_ignores_stale_dates():
dates = {0: date(2026, 10, 5), 2: date(2026, 10, 7)}
assert planned_date(dates, 5, today=date(2026, 10, 7)) == date(2026, 10, 10)
assert planned_date(dates, 2, today=date(2026, 10, 7)) == date(2026, 10, 7)
assert planned_date({}, 3, today=date(2026, 10, 7)) is None
# Presenter moved the clock manually past the chained date → fall back to "next occurrence".
assert planned_date(dates, 3, today=date(2026, 10, 12)) is None
def test_parse_clock_now():
assert parse_clock_now({"now": "2026-10-05T08:00:00+03:00"}) == datetime(2026, 10, 5, 8, 0, tzinfo=MSK)
with pytest.raises(ValueError):
parse_clock_now({})
# ------------------------------------------------------------------------------------------------ chat log
def test_chatlog_normalizes_and_tracks_active_keyboard():
assert normalize_reply({"text": None, "buttons": [[{"label": "x"}], "bad"]}) == {
"dir": "out",
"text": "",
"kind": "normal",
"buttons": [],
}
chat = ChatLog()
chat.add_replies([_reply("a", ("Да", "y")), {"text": "b"}])
chat.add_in("Да", pressed=True)
assert chat.last_buttons_index() == 0
assert [r["text"] for r in chat.recent_replies(1)] == ["b"]
chat.add_history([{"direction": "in", "text": "hi"}, {"direction": "out", "text": "yo", "kind": "info"}])
assert chat.entries[-1]["kind"] == "info" and chat.entries[-2]["dir"] == "in"
# ------------------------------------------------------------------------------------------------ runner
class FakeApi:
"""Demo backend double: a virtual clock with scheduled pushes and scripted dialog answers."""
def __init__(self, now: datetime):
self.now = now
self.calls: list[tuple] = []
self.scheduled: list[tuple[datetime, dict[str, Any]]] = []
self.box: list[dict[str, Any]] = []
self.color = "green"
self.fail_on: str | None = None
self.answers: dict[str, list[dict[str, Any]]] = {}
def _maybe_fail(self, what: str) -> None:
if self.fail_on == what:
raise ApiError(f"{what}: HTTP 500")
def push_at(self, hhmm: str, day: date, reply: dict[str, Any]) -> None:
h, m = map(int, hhmm.split(":"))
self.scheduled.append((datetime(day.year, day.month, day.day, h, m, tzinfo=MSK), reply))
def clock(self) -> dict[str, Any]:
self._maybe_fail("clock")
return {"now": self.now.isoformat(), "virtual": True, "offset_days": 0}
def advance(self, days: float = 0, hours: float = 0) -> dict[str, Any]:
self._maybe_fail("advance")
self.calls.append(("advance", round(hours, 3)))
self.now += timedelta(days=days, hours=hours)
due = [(t, r) for t, r in self.scheduled if t <= self.now]
self.scheduled = [(t, r) for t, r in self.scheduled if t > self.now]
self.box.extend(r for _, r in due)
return self.clock()
def send_event(self, token, type_, *, text=None, button=None, params=None):
self._maybe_fail(type_)
self.calls.append((type_, text or button or params))
return self.answers.get(text or button or type_, [{"text": "ок"}])
def outbox(self, token):
out, self.box = self.box, []
return out
def patients(self, doctor):
self._maybe_fail("patients")
return [{"token": "tok-other", "status_color": "red"}, {"token": "tok-andrey", "status_color": self.color}]
def _runner(api: FakeApi, state: PlaybackState | None = None, chat: ChatLog | None = None) -> PlaybackRunner:
sleeps: list[float] = []
r = PlaybackRunner(api, "tok-andrey", chat or ChatLog(), state or PlaybackState(), delay=0.5, sleep=sleeps.append)
r.sleeps = sleeps # type: ignore[attr-defined]
return r
SCENARIO = parse_scenario(
_jsonl(
{"day": 1, "note": "приём", "actions": [{"advance_to": "08:00"}, {"press": "Принял"}]},
{
"day": 2,
"actions": [
{"advance_to": "08:00"},
{"press": "принял"},
{"advance_to": "20:00"},
{"text": "подташнивает с утра"},
{"expect_color": "yellow"},
],
},
{"day": 5, "actions": [{"advance_to": "20:00"}, {"text": "брошу"}]},
)
)
def test_runner_plays_day_in_order_with_dynamic_button_data():
api = FakeApi(datetime(2026, 10, 5, 21, 0, tzinfo=MSK))
api.push_at("08:00", date(2026, 10, 6), _reply("Пора принять сертралин", ("Принял", "med:taken:42")))
runner = _runner(api)
logs = list(runner.play_day(SCENARIO, 1))
assert api.calls == [("advance", 11.0), ("button", "med:taken:42")]
assert [log.status for log in logs] == ["info", "ok", "ok", "ok"]
assert runner.state.done == [1] and runner.state.dates[1] == date(2026, 10, 6)
assert runner.chat.entries[1] == {"dir": "in", "text": "Принял", "pressed": True}
assert runner.sleeps == [0.5] # one pause between two steps
def test_runner_chains_next_day_date_and_soft_color_check():
api = FakeApi(datetime(2026, 10, 5, 21, 0, tzinfo=MSK))
api.push_at("08:00", date(2026, 10, 6), _reply("доза 1", ("Принял", "med:taken:1")))
api.push_at("08:00", date(2026, 10, 7), _reply("доза 2", ("Принял", "med:taken:2")))
runner = _runner(api)
list(runner.play_day(SCENARIO, 1))
logs = list(runner.play_day(SCENARIO, 2))
assert api.calls[2:] == [
("advance", 24.0),
("button", "med:taken:2"),
("advance", 12.0),
("text", "подташнивает с утра"),
]
color_log = logs[-2]
assert color_log.status == "warn" and "green" in color_log.message
assert runner.state.done == [1, 2] and runner.state.failed is None
def test_scroll_to_plays_all_unplayed_days_fast():
api = FakeApi(datetime(2026, 10, 5, 7, 0, tzinfo=MSK))
api.color = "yellow"
for d in (5, 6):
api.push_at("08:00", date(2026, 10, d), _reply("доза", ("Принял", f"med:taken:{d}")))
runner = _runner(api)
runner.delay = 0
logs = list(runner.scroll_to(SCENARIO, 5))
assert runner.state.done == [1, 2, 5]
# Day 1 lands on today 08:00 (nothing played yet), day 5 is chained: 5 − 2 = 3 days after day 2.
assert runner.state.dates == {1: date(2026, 10, 5), 2: date(2026, 10, 6), 5: date(2026, 10, 9)}
assert all(log.status != "fail" for log in logs)
assert runner.sleeps == []
assert list(runner.scroll_to(SCENARIO, 5))[0].status == "info" # nothing left to scroll
def test_missing_button_pauses_and_resume_can_skip_or_retry():
api = FakeApi(datetime(2026, 10, 5, 21, 0, tzinfo=MSK)) # no reminder scheduled → button never shows up
runner = _runner(api)
logs = list(runner.play_day(SCENARIO, 1))
assert logs[-1].status == "fail" and "не найдена" in logs[-1].message
assert runner.state.failed is not None and (runner.state.failed.day, runner.state.failed.step) == (1, 1)
assert runner.state.done == []
assert runner.sleeps == [0.5] + [runner.retry_wait] * runner.press_retries # step pause, then retries
api.box.append(_reply("доза", ("Принял", "med:taken:7")))
retry = list(runner.resume(SCENARIO, skip=False))
assert retry[-1].status == "ok" and ("button", "med:taken:7") in api.calls
assert runner.state.failed is None and runner.state.done == [1]
def test_resume_skip_continues_until_target_day():
api = FakeApi(datetime(2026, 10, 5, 21, 0, tzinfo=MSK))
runner = _runner(api)
runner.delay = 0
list(runner.scroll_to(SCENARIO, 2)) # fails on day 1 «Принял»
assert runner.state.failed.until == 2
list(runner.resume(SCENARIO, skip=True)) # skip the press, play day 1 rest and day 2 → fails on day 2 press
assert runner.state.done == [1]
assert (runner.state.failed.day, runner.state.failed.step) == (2, 1)
def test_api_errors_never_escape_the_runner():
api = FakeApi(datetime(2026, 10, 5, 19, 0, tzinfo=MSK))
api.fail_on = "text"
runner = _runner(api)
logs = list(runner.play_day(SCENARIO, 5))
assert logs[-1].status == "fail" and "HTTP 500" in logs[-1].message
assert runner.state.failed is not None
api.fail_on = "clock"
logs = list(_runner(api).play_day(SCENARIO, 5))
assert logs[-1].status == "fail" and "часы" in logs[-1].message
api.fail_on = "patients"
color_only = parse_scenario(_jsonl({"day": 0, "actions": [{"expect_color": "green"}]}))
logs = list(_runner(api).play_day(color_only, 0))
assert logs[1].status == "warn" and logs[-1].status == "ok"
def test_past_target_is_skipped_with_warning():
api = FakeApi(datetime(2026, 10, 6, 21, 0, tzinfo=MSK))
state = PlaybackState(dates={5: date(2026, 10, 6)})
logs = list(_runner(api, state).play_day(SCENARIO, 5)) # replaying day 5 after 20:00 the same evening
assert logs[1].status == "warn" and "уже прошло" in logs[1].message
assert ("text", "брошу") in api.calls and not any(c[0] == "advance" for c in api.calls)
def test_play_unknown_day_is_info():
logs = list(_runner(FakeApi(datetime(2026, 10, 5, 8, 0, tzinfo=MSK))).play_day(SCENARIO, 42))
assert [log.status for log in logs] == ["info"]
def test_start_action_sends_code():
api = FakeApi(datetime(2026, 10, 5, 8, 0, tzinfo=MSK))
sc = parse_scenario(_jsonl({"day": 0, "actions": [{"start": "DEMO-0001"}, {"start": ""}]}))
list(_runner(api).play_day(sc, 0))
assert api.calls == [("start", {"code": "DEMO-0001"}), ("start", {})]
# ------------------------------------------------------------------------------------------------ http client
def _client(handler) -> ConsoleApi:
return ConsoleApi("http://api", client=httpx.Client(transport=httpx.MockTransport(handler)))
def test_console_api_paths_and_bodies():
seen: list[tuple[str, str, Any]] = []
def handler(req: httpx.Request) -> httpx.Response:
body = json.loads(req.content) if req.content else None
seen.append((req.method, req.url.path, body))
if req.url.path.endswith("/events"):
return httpx.Response(200, json={"replies": [{"text": "hi"}]})
if req.url.path.endswith("/outbox"):
return httpx.Response(200, json={"replies": []})
return httpx.Response(200, json={"now": "2026-10-05T08:00:00+03:00"})
api = _client(handler)
assert api.send_event("tok-andrey", "button", button="med:taken:1") == [{"text": "hi"}]
assert api.outbox("tok-andrey") == []
api.advance(hours=1.5)
assert seen == [
("POST", "/api/v1/dialog/tok-andrey/events", {"type": "button", "params": {}, "button": "med:taken:1"}),
("GET", "/api/v1/dialog/tok-andrey/outbox", None),
("POST", "/api/v1/demo/clock/advance", {"days": 0, "hours": 1.5}),
]
def test_console_api_errors_become_api_error_and_personas_fall_back(monkeypatch):
def down(req: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused")
api = _client(down)
with pytest.raises(ApiError, match="недоступен"):
api.clock()
assert api.health() is False
monkeypatch.setenv("DEMO_PERSONA_TOKEN", "tok-andrey")
personas, error = personas_or_fallback(api)
assert personas[0]["token"] == "tok-andrey" and error
api = _client(lambda req: httpx.Response(500))
with pytest.raises(ApiError, match="HTTP 500"):
api.outbox("t")
ok = _client(lambda req: httpx.Response(200, json=[{"token": "tok-andrey", "display_name": "Андрей С."}]))
personas, error = personas_or_fallback(ok)
assert error is None and personas[0]["display_name"] == "Андрей С."
def test_personas_fall_back_to_doctor_patients_with_hero_first():
def handler(req: httpx.Request) -> httpx.Response:
if req.url.path.endswith("/demo/personas"):
return httpx.Response(200, json=[])
return httpx.Response(
200, json=[{"token": "h1", "display_name": "Сергей П."}, {"token": "h2", "display_name": "Андрей С."}]
)
personas, error = personas_or_fallback(_client(handler))
assert [p["token"] for p in personas] == ["h2", "h1"] and "пациенты врача" in error
def test_scenario_path_guessed_from_name_or_token(tmp_path):
(tmp_path / "data" / "synthetic").mkdir(parents=True)
(tmp_path / "data" / "synthetic" / "andrey.jsonl").write_text("", encoding="utf-8")
assert persona_slug("Андрей С.") == "andrey" and persona_slug("Наталья Ф.") == "natalya"
assert default_scenario_path({"token": "h", "display_name": "Андрей С."}, tmp_path) == "data/synthetic/andrey.jsonl"
assert default_scenario_path({"token": "tok-andrey"}, tmp_path) == "data/synthetic/andrey.jsonl"
assert default_scenario_path({"token": "x", "display_name": "Ольга Н."}, tmp_path) is None
assert default_scenario_path({"token": "x", "scenario": "s.jsonl"}, tmp_path) == "s.jsonl"
+64
View File
@@ -0,0 +1,64 @@
"""Demo console pages (#17) render without crashing even when the API is down (robustness of the live demo)."""
from pathlib import Path
import pytest
from streamlit.testing.v1 import AppTest
PAGES = Path(__file__).resolve().parents[1] / "dashboard" / "pages"
@pytest.fixture
def api_down(monkeypatch):
monkeypatch.setenv("API_BASE_URL", "http://127.0.0.1:9") # nothing listens on the discard port
monkeypatch.setenv("DEMO_PERSONA_TOKEN", "tok-andrey")
monkeypatch.delenv("PATIENT_WEB_URL", raising=False)
def _page(name: str) -> AppTest:
return AppTest.from_file(str(PAGES / name), default_timeout=30)
def test_console_page_survives_api_down_and_button_clicks(api_down, tmp_path):
scenario = tmp_path / "s.jsonl"
scenario.write_text('{"day": 1, "note": "утро", "actions": [{"advance_to": "08:00"}]}\n', encoding="utf-8")
at = _page("2_Демо-консоль.py")
at.session_state["console_scenario_path:tok-andrey"] = str(scenario)
at.run()
assert not at.exception
assert any("Часы недоступны" in w.value for w in at.warning)
labels = [b.label for b in at.button]
assert {"+1 час", "Следующий день", "🚀 Начать", "▶ Проиграть день 1"} <= set(labels)
at.button(key="console-clock-d1").click().run() # «Следующий день» with the API down
assert not at.exception
at.button(key="console-chat-start").click().run()
assert not at.exception
next(b for b in at.button if b.label == "▶ Проиграть день 1").click().run()
assert not at.exception
state = at.session_state["console_playback:tok-andrey"]
assert state.failed is not None and state.done == [] # playback paused, page alive
at.run()
assert not at.exception
assert any("Повторить шаг" in b.label for b in at.button), [b.label for b in at.button]
def test_console_page_without_scenario_file(api_down):
at = _page("2_Демо-консоль.py")
at.session_state["console_scenario_path:tok-andrey"] = "data/synthetic/__missing__.jsonl"
at.run()
assert not at.exception
assert any("Сценарий не загружен" in i.value for i in at.info)
def test_split_screen_survives_api_down_and_uses_iframe_when_configured(api_down, monkeypatch):
at = _page("3_Сплит-экран.py")
at.run()
assert not at.exception
assert any("Алерты недоступны" in w.value for w in at.warning)
monkeypatch.setenv("PATIENT_WEB_URL", "http://localhost:5173")
at = _page("3_Сплит-экран.py")
at.run()
assert not at.exception
assert any("http://localhost:5173" in c.value for c in at.caption)
+227
View File
@@ -0,0 +1,227 @@
"""Pure dashboard logic: ordering, labels, dose strip, scales config, SNILS, chart builders, page discovery."""
import random
from datetime import date, datetime, timedelta
from pathlib import Path
from zoneinfo import ZoneInfo
from app.api import fixtures
from app.api.schemas import AlertOut, DiscontinuationRisk, DoseOut, SeriesPoint
from dashboard.app import discover_pages, slugify
from dashboard.components import charts, ui
from dashboard.components import helpers as h
TZ = ZoneInfo("Europe/Moscow")
def _risk(score: int, level: str = "low", factors: list[str] | None = None) -> DiscontinuationRisk:
return DiscontinuationRisk(score=score, level=level, factors=factors or [])
def test_sort_red_yellow_grey_green_then_risk():
items = list(reversed(fixtures.PATIENTS))
items.append(fixtures.PATIENTS[1].model_copy(update={"token": "g", "display_name": "Г", "status_color": "grey"}))
out = h.sort_patients(items)
colors = [p.status_color for p in out]
assert colors == sorted(colors, key=h.STATUS_ORDER.__getitem__)
assert out[0].token == "tok-andrey"
yellows = [p for p in out if p.status_color == "yellow"]
assert yellows[0].display_name == "Сергей П." # highest discontinuation risk among yellow
assert [p.discontinuation_risk.score for p in yellows] == sorted(
(p.discontinuation_risk.score for p in yellows), reverse=True
)
def test_filter_and_kpis():
items = fixtures.PATIENTS
assert {p.status_color for p in h.filter_patients(items, "yellow")} == {"yellow"}
assert [p.display_name for p in h.filter_patients(items, query="андр")] == ["Андрей С."]
assert len(h.filter_patients(items, query="пароксетин")) == 1
k = h.kpis(items)
assert (k.total, k.red, k.yellow, k.green, k.grey, k.high_risk) == (8, 1, 4, 3, 0, 1)
def test_trend_arrows():
assert h.trend_view("up").text == "↑" and h.trend_view("up").tone == "red"
assert h.trend_view("down").text == "↓" and h.trend_view("down").tone == "green"
assert h.trend_view("flat").text == "→"
assert h.trend_view("unknown").tone == "grey"
def test_risk_badge_and_tooltip():
v = h.risk_view(_risk(70, "high", ["пропуски 3 из 7", "нет ответа 2 дня"]))
assert v.text == "Высокий · 70" and v.tone == "red"
assert "• пропуски 3 из 7\n• нет ответа 2 дня" in v.hint
assert h.risk_view(_risk(35, "medium")).tone == "yellow"
assert "значимых факторов нет" in h.risk_view(_risk(5)).hint
def test_adherence_thresholds():
assert h.adherence_view(None).text == "—"
assert h.adherence_view(1.0).tone == "green"
assert h.adherence_view(0.8).tone == "green"
assert h.adherence_view(0.7).tone == "yellow"
assert h.adherence_view(0.57) == h.View("57%", "red")
def test_level_tone_matches_adr006():
assert h.level_tone("acute") == "red"
assert h.level_tone("high") == h.level_tone("moderate") == "yellow"
assert h.level_tone("info") == "grey"
def test_severity_bands_come_from_config():
bands = h.severity_bands("phq9")
assert [b.lo for b in bands] == [0, 5, 10, 15, 20]
assert h.severity_label("phq9", 18) == "Умеренно-тяжёлая"
assert h.severity_label("pcl5", 44) == "Вероятное ПТСР"
assert h.severity_label("pcl5", None) == ""
assert h.severity_bands("no_such_scale") == ()
def test_last_and_prev():
card = fixtures.andrey_card()
assert h.last_and_prev(card.series, "phq9") == (18, 13)
assert h.last_and_prev(card.series, "gad7") == (None, None)
def test_dose_grid_fills_gaps_and_counts():
d0 = datetime(2026, 9, 21, 8, tzinfo=TZ)
doses = [
DoseOut(planned_at=d0, drug="сертралин", status="taken"),
DoseOut(planned_at=d0 + timedelta(hours=12), drug="сертралин", status="missed"),
DoseOut(planned_at=d0 + timedelta(days=2), drug="сертралин", status="skipped"),
DoseOut(planned_at=d0 + timedelta(days=3), drug="сертралин", status="pending"),
]
g = h.dose_grid(doses)
assert g.days == [date(2026, 9, 21) + timedelta(days=i) for i in range(4)]
assert g.slots == ["08:00", "20:00"]
assert g.cells[("20:00", date(2026, 9, 21))] == "missed"
assert ("08:00", date(2026, 9, 22)) not in g.cells # no dose planned that day → empty cell
assert g.counts == {"taken": 1, "missed": 1, "skipped": 1, "pending": 1}
assert g.adherence == 1 / 3 # pending is not counted
def test_dose_grid_empty():
g = h.dose_grid([])
assert g.days == [] and g.adherence is None
def test_dose_grid_converts_to_moscow_time():
utc = datetime(2026, 9, 21, 5, tzinfo=ZoneInfo("UTC"))
g = h.dose_grid([DoseOut(planned_at=utc, drug="x", status="taken")])
assert g.slots == ["08:00"]
def _alert(i: int, level: str = "moderate", acked: bool = False, hours: int = 0) -> AlertOut:
ts = datetime(2026, 10, 3, 9, tzinfo=TZ) + timedelta(hours=hours)
return AlertOut(
id=i,
token="t",
patient_name="П",
ts=ts,
level=level,
color=h.level_tone(level),
code="c",
reason="r",
acked_by="d" if acked else None,
acked_at=ts if acked else None,
)
def test_alert_ordering_new_and_local_acks():
alerts = [_alert(1, "moderate", hours=5), _alert(2, "acute", hours=1), _alert(3, "high", acked=True, hours=9)]
assert [a.id for a in h.sort_alerts(alerts)] == [2, 1, 3]
assert [a.id for a in h.split_new(alerts, {1})] == [2, 3]
at = datetime(2026, 10, 3, 22, tzinfo=TZ)
out = h.apply_local_acks(alerts, {2: ("doc-smirnova", at)})
assert out[1].acked_by == "doc-smirnova" and out[1].acked_at == at
assert out[0].acked_at is None
def test_russian_formatting():
assert [h.age_label(n) for n in (21, 23, 34, 41, 45, 11, 112)] == [
"21 год",
"23 года",
"34 года",
"41 год",
"45 лет",
"11 лет",
"112 лет",
]
assert h.age_label(0) == "возраст не указан"
assert h.absolute_link("/console?code=X", "http://h:8501/") == "http://h:8501/console?code=X"
assert h.absolute_link("https://t.me/b?start=X", "http://h") == "https://t.me/b?start=X"
today = date(2026, 10, 3)
assert h.visit_label(date(2026, 10, 18), today) == "визит 18 окт · через 15 дней"
assert h.visit_label(date(2026, 10, 4), today) == "визит 4 окт · через 1 день"
assert h.visit_label(today, today) == "визит сегодня"
assert "просрочен" in h.visit_label(date(2026, 10, 1), today)
assert h.visit_label(None, today) == "визит не назначен"
assert h.when_label(datetime(2026, 10, 3, 21, tzinfo=TZ), today) == "сегодня, 21:00"
def test_snils_checksum():
assert h.snils_checksum("112233445") == 95 # well-known valid example 112-233-445 95
assert h.is_valid_snils("112-233-445 95")
assert not h.is_valid_snils("112-233-445 96")
rng = random.Random(1)
for _ in range(50):
s = h.synthetic_snils(rng)
assert s.startswith("000-") and h.is_valid_snils(s)
def test_ui_row_escapes_and_shows_values():
p = fixtures.PATIENTS[0].model_copy(update={"last_event": "<script>x</script>"})
html = ui.patient_row(p, detailed=True)
assert "<script>" not in html and "&lt;script&gt;" in html
assert "Андрей С." in html and "день терапии 13" in html and "↑" in html
def test_home_fragments_escape_api_values():
payload = '<img src=x onerror="alert(1)">'
p = fixtures.PATIENTS[0].model_copy(
update={
"display_name": payload,
"drug": payload,
"last_event": payload,
"discontinuation_risk": _risk(70, "high", [payload]),
}
)
for html in (ui.patient_row(p), ui.patient_row(p, detailed=True), ui.workspace_header(payload, payload)):
assert "<img" not in html
assert "&lt;img" in html
def test_scale_figure_marks_unscheduled_and_bands():
card = fixtures.andrey_card()
fig = charts.scale_figure(card.series, "phq9", "PHQ-9", card.prescriptions[0].start_date)
assert fig is not None
trace = fig.data[0]
assert list(trace.marker.symbol) == ["circle", "circle", "diamond-open"]
assert len(fig.layout.shapes) == 5 # severity bands from config/scales/phq9.yaml
assert charts.scale_figure([], "phq9", "PHQ-9") is None
def test_wellbeing_figure_small_multiples():
card = fixtures.andrey_card()
fig = charts.wellbeing_figure(card.series)
assert fig is not None and len(fig.data) == 3
assert charts.wellbeing_figure([SeriesPoint(day=date(2026, 1, 1), metric="phq9", value=3)]) is None
def test_page_discovery_orders_and_sections(tmp_path: Path):
for name in ("1_Пациенты.py", "1b_Подключить_пациента.py", "2_Демо-консоль.py", "3_Сплит-экран.py", "helper.py"):
(tmp_path / name).write_text("", encoding="utf-8")
(tmp_path / "4_Что-то.py").write_text('NAV_ICON = ":material/star:"\nNAV_SECTION = "Прочее"\n', encoding="utf-8")
specs = discover_pages(tmp_path)
assert [s.title for s in specs] == ["Пациенты", "Подключить пациента", "Демо-консоль", "Сплит-экран", "Что-то"]
assert [s.section for s in specs][:4] == ["Кабинет врача", "Кабинет врача", "Демо", "Демо"]
assert specs[4].icon == ":material/star:" and specs[4].section == "Прочее"
assert specs[2].url_path == "demo-konsol"
assert slugify("Сплит-экран") == "split-ekran"
def test_real_pages_dir_has_doctor_pages():
titles = [s.title for s in discover_pages()]
assert titles[:2] == ["Пациенты", "Подключить пациента"]
+228
View File
@@ -0,0 +1,228 @@
"""Streamlit AppTest smoke tests of the doctor pages against an in-memory API (fixtures over httpx.MockTransport)."""
from pathlib import Path
import httpx
import pytest
from streamlit.testing.v1 import AppTest
from app.api import fixtures
from dashboard.api_client import ApiClient
from dashboard.components import state
PAGES = Path(__file__).resolve().parents[1] / "dashboard" / "pages"
LIST_PAGE = str(PAGES / "1_Пациенты.py")
CONNECT_PAGE = str(PAGES / "1b_Подключить_пациента.py")
def _dump(obj):
return [o.model_dump(mode="json") for o in obj] if isinstance(obj, list) else obj.model_dump(mode="json")
class FakeApi:
def __init__(self) -> None:
self.calls: list[tuple[str, str]] = []
self.extra_alerts: list = []
self.patients = list(fixtures.PATIENTS)
def __call__(self, req: httpx.Request) -> httpx.Response:
path = req.url.path.removeprefix("/api/v1")
self.calls.append((req.method, path))
if path == "/doctors/doc-smirnova/patients":
return httpx.Response(200, json=_dump(self.patients))
if path.startswith("/patients/tok-"):
card = fixtures.card(path.rsplit("/", 1)[1])
return httpx.Response(200, json=_dump(card)) if card else httpx.Response(404)
if path == "/doctors/doc-smirnova/alerts":
return httpx.Response(200, json=_dump(self.extra_alerts + fixtures.all_alerts()))
if path.startswith("/alerts/") and path.endswith("/ack"):
alert = next(a for a in fixtures.all_alerts() if a.id == int(path.split("/")[2]))
acked = alert.model_copy(update={"acked_by": "doc-smirnova", "acked_at": alert.ts})
return httpx.Response(200, json=_dump(acked))
if path == "/patients/connect":
return httpx.Response(
200, json={"token": "tok-new", "code": "DEMO-0001", "deep_link": "https://t.me/b?start=DEMO-0001"}
)
if path == "/demo/clock":
return httpx.Response(200, json={"now": "2026-10-03T21:05:00+03:00", "virtual": True, "offset_days": 0})
return httpx.Response(404)
@pytest.fixture
def api(monkeypatch) -> FakeApi:
fake = FakeApi()
client = ApiClient("http://api.test", "doc-smirnova", transport=httpx.MockTransport(fake))
monkeypatch.setattr(state, "client", lambda: client)
return fake
def _html(at: AppTest) -> str:
return "\n".join(str(el.proto.body) for el in at.get("html"))
def test_list_page_renders_sorted_patients(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
assert not at.exception
html = _html(at)
assert "Кабинет д-ра Смирновой Е. А." in html
assert "Пациенты под наблюдением" in html
assert html.index("Андрей С.") < html.index("Сергей П.") < html.index("Павел Р.")
assert "Высокий · 70" in html # discontinuation-risk badge
open_buttons = [b for b in at.button if b.label == "Открыть"]
assert len(open_buttons) == len(fixtures.PATIENTS)
def test_list_filter_and_open_card(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
at.segmented_control(key="status_filter").set_value("Срочно").run()
at.text_input(key="patient_query").set_value("андр").run()
at.toggle(key="patient_details").set_value(True).run()
assert len([b for b in at.button if b.label == "Открыть"]) == 1
at.button(key="open-tok-andrey").click().run()
assert not at.exception
assert at.query_params["patient"] in ("tok-andrey", ["tok-andrey"])
html = _html(at)
assert "PHQ-9 п.9 = 2" in html and "План безопасности" in html
assert "Решения принимает врач" in html
assert ("GET", "/patients/tok-andrey") in api.calls
next(b for b in at.button if b.label == "Все пациенты").click().run()
assert not at.exception
assert not at.query_params.get("patient")
assert len([b for b in at.button if b.label == "Открыть"]) == 1 # the selected filter is retained
assert at.text_input(key="patient_query").value == "андр"
assert at.toggle(key="patient_details").value is True
@pytest.mark.parametrize("choice,expected", [("Внимание", 4), ("Без уведомлений", 3), ("Все пациенты", 8)])
def test_home_status_filters(api, choice, expected):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
at.segmented_control(key="status_filter").set_value(choice).run()
assert not at.exception
assert len([b for b in at.button if b.label == "Открыть"]) == expected
assert f"Показано {expected} из 8 пациентов" in _html(at)
def test_home_search_empty_result_and_reset(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
at.text_input(key="patient_query").set_value(" ПАРОКСЕТИН ").run()
assert len([b for b in at.button if b.label == "Открыть"]) == 1
assert "Сергей П." in _html(at)
at.segmented_control(key="status_filter").set_value("Без уведомлений").run()
assert "Пациенты не найдены" in _html(at)
next(b for b in at.button if b.label == "Сбросить фильтры").click().run()
assert not at.exception
assert len([b for b in at.button if b.label == "Открыть"]) == 8
assert at.text_input(key="patient_query").value == ""
def test_home_empty_cabinet_keeps_connect_action(api):
api.patients = []
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
assert not at.exception
assert "Пациентов пока нет" in _html(at)
assert "Показано 0 из 0 пациентов" in _html(at)
assert any(b.label == "Подключить пациента" for b in at.button)
assert not any(b.label == "Открыть" for b in at.button)
def test_home_details_preserve_clinical_information(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
assert "PHQ-9" in _html(at) and "PCL-5" in _html(at)
at.toggle(key="patient_details").set_value(True).run()
assert not at.exception
html = _html(at)
assert "сертралин 50 мг" in html
assert "PHQ-9 п.9 = 2 — кризисный протокол" in html
assert "пропуски 3 из 7" in html
def test_home_info_only_patient_and_missing_scores(api):
api.patients = [
fixtures.PATIENTS[0].model_copy(
update={
"status_color": "grey",
"top_alert_level": "info",
"phq9_last": None,
"pcl5_last": None,
"adherence_7d": None,
"open_alerts": 1,
}
)
]
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
at.segmented_control(key="status_filter").set_value("Нет ответа").run()
assert not at.exception
assert len([b for b in at.button if b.label == "Открыть"]) == 1
assert "Из них только инфо: 1" in _html(at)
assert 'mt-dim">—' in _html(at)
def test_card_ack_posts_and_marks_alert(api):
at = AppTest.from_file(LIST_PAGE, default_timeout=20)
at.query_params["patient"] = "tok-andrey"
at.run()
assert not at.exception
assert at.button(key="ack-3").label == "Подтвердить"
at.button(key="ack-3").click().run()
assert ("POST", "/alerts/3/ack") in api.calls
assert not [b for b in at.button if b.key == "ack-3"] # acked alert has no button any more
def test_api_down_shows_banner(monkeypatch):
def refuse(req: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("refused", request=req)
client = ApiClient("http://api.test", transport=httpx.MockTransport(refuse))
monkeypatch.setattr(state, "client", lambda: client)
at = AppTest.from_file(LIST_PAGE, default_timeout=20).run()
assert not at.exception
assert "API недоступен" in _html(at)
assert any(b.label == "Повторить загрузку" for b in at.button)
assert "Всего пациентов" not in _html(at) # failed loading is not a zero-patient cabinet
def test_connect_flow_shows_code(api):
at = AppTest.from_file(CONNECT_PAGE, default_timeout=20).run()
assert not at.exception
at.button[0].click().run() # «Заполнить демо-данными»
at.checkbox(key="cp_consent").check().run()
assert at.text_input(key="cp_name").value == "Андрей С."
submit = next(b for b in at.button if b.label == "Подключить и получить код")
submit.click().run()
assert not at.exception
assert ("POST", "/patients/connect") in api.calls
assert "DEMO-0001" in _html(at)
assert at.get("image") # QR image
def test_connect_requires_consent(api):
at = AppTest.from_file(CONNECT_PAGE, default_timeout=20).run()
at.text_input(key="cp_name").set_value("Пётр К.").run()
next(b for b in at.button if b.label == "Подключить и получить код").click().run()
assert at.error and "согласие" in at.error[0].value
assert ("POST", "/patients/connect") not in api.calls
def test_app_entry_navigation_and_acute_banner(api):
at = AppTest.from_file(str(PAGES.parent / "app.py"), default_timeout=20).run()
assert not at.exception
html = _html(at)
assert "Кризисный сигнал · Андрей С." in html # open acute alert pinned above every page
assert "Кабинет д-ра Смирновой Е. А." in html # default page = patient list
assert "Прототип на синтетических данных" in html
assert ("GET", "/doctors/doc-smirnova/alerts") in api.calls
def test_new_alert_toasts_once(api):
at = AppTest.from_file(str(PAGES.parent / "app.py"), default_timeout=20).run()
assert not at.toast # alerts open at session start are not "new"
new = fixtures.all_alerts()[0].model_copy(
update={"id": 10, "token": "tok-sergey", "patient_name": "Сергей П.", "reason": "C-SSRS: намерение"}
)
api.extra_alerts.append(new)
at.run()
assert not at.exception
assert any("Сергей П." in t.value for t in at.toast)
assert "Кризисный сигнал · Сергей П." in _html(at)
at.run()
assert not any("Сергей П." in t.value for t in at.toast)
+188
View File
@@ -0,0 +1,188 @@
"""Knowledge base in the dialog (#64): «Вопрос о лечении» menu, questions in idle free text, routing order
(safety → check-in signals → drug question → guidelines → support)."""
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Any
import pytest
from app.config import Settings
from app.core import clock
from app.db import repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Prescription
from app.dialog.contracts import Event, Reply
from app.dialog.engine import Engine
from app.dialog.flows.knowledge import QUESTION_STATE, load_knowledge_texts, looks_like_question
from app.integrations.base import DrugSection, integrations
from app.llm import med_qa as med_qa_module
from app.llm.gateway import LLMGateway
from app.llm.med_qa import load_med_qa_texts
from app.llm.mock import MockTasks
from app.llm.rag import fallback_text
from app.llm.rag import qa as rag_qa
from app.llm.texts import render_phrase
from app.services.contracts import NoopMonitoring
TOKEN = "k" * 32
MEM = "sqlite+aiosqlite:///:memory:"
NOW = datetime(2026, 10, 7, 8, 0)
SUPPORT = render_phrase("support", {})
KB = load_knowledge_texts()
class FakeDrugInfo:
async def sections(self, inn: str) -> list[DrugSection]:
text = "Улучшение обычно отмечается через 2–4 недели регулярного приёма."
return [DrugSection(inn=inn, section="onset", text=text, source="синтетика")]
@pytest.fixture
async def engine(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[Engine]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
monkeypatch.setattr(integrations, "drug_info", FakeDrugInfo())
monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks()))
monkeypatch.setattr(rag_qa, "get_gateway", lambda: LLMGateway(MockTasks()))
clock.set_now(NOW)
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with session.get_clinical_session() as s:
await repo.upsert_profile(
s, token=TOKEN, doctor_id="doc", modules=["veteran"], sex="m", birth_year=1990, therapy_start=NOW.date()
)
s.add(Prescription(token=TOKEN, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}))
await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True}, now=clock.now())
yield Engine(gateway=LLMGateway(MockTasks()), monitoring=NoopMonitoring())
clock.reset()
await session.configure()
async def press(e: Engine, data: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="button", button=data))
async def say(e: Engine, t: str) -> list[Reply]:
return await e.handle(TOKEN, Event(type="text", text=t))
async def state() -> tuple[str, dict[str, Any]]:
async with session.get_clinical_session() as s:
row = await repo.get_dialog_state(s, TOKEN)
assert row is not None
return row.state, dict(row.data)
def buttons(replies: list[Reply]) -> list[str]:
return [b.data for r in replies for row in r.buttons for b in row]
def joined(replies: list[Reply]) -> str:
return "\n".join(r.text for r in replies)
# --- question detection --------------------------------------------------------------------------------
@pytest.mark.parametrize(
("text", "expected"),
[
("что такое флэшбек", True),
("Можно ли вылечить ПТСР", True),
("кошмары снятся, это нормально?", True),
("сегодня 6 из 10", False),
("спал плохо", False),
("какой-то день", False), # "как" only as a whole word at the start
("как дела?", False), # small talk
("Привет! Как ты?", False),
("[NAME], что такое ПТСР", True), # scrub placeholder before the question
],
)
def test_looks_like_question(text: str, expected: bool) -> None:
assert looks_like_question(text) is expected
# --- menu «Вопрос о лечении» ------------------------------------------------------------------------------
async def test_menu_has_knowledge_button(engine: Engine) -> None:
r = await engine.handle(TOKEN, Event(type="start"))
assert "menu:kb_question" in buttons(r)
async def test_menu_question_answers_from_guidelines(engine: Engine) -> None:
r = await press(engine, "menu:kb_question")
assert joined(r) == KB.prompt
assert (await state())[0] == QUESTION_STATE
r = await say(engine, "что такое флэшбек?")
text = joined(r)
assert "Флэшбек (flashback" in text
assert "Источник: КР ПТСР 2023, стр. 2" in text
assert rag_qa.load_rag_texts()["patient"].doctor_note in text
assert "menu:kb_question" in buttons(r) # back to the menu
assert (await state())[0] == "idle"
async def test_menu_question_without_answer_sends_to_doctor(engine: Engine) -> None:
await press(engine, "menu:kb_question")
r = await say(engine, "какой рецепт борща")
assert fallback_text("patient") in joined(r)
assert (await state())[0] == "idle"
@pytest.mark.parametrize("question", ["можно увеличить дозу?", "можно пить полтаблетки?"])
async def test_dose_question_redirected_to_doctor(engine: Engine, question: str) -> None:
await press(engine, "menu:kb_question")
r = await say(engine, question)
assert joined(r).startswith(KB.dose_redirect)
assert "клинических рекомендациях" not in joined(r)
async def test_button_instead_of_question_leaves_the_flow(engine: Engine) -> None:
await press(engine, "menu:kb_question")
r = await press(engine, "menu:contact_doctor")
assert "врач" in joined(r)
assert (await state())[0] == "idle"
async def test_scrubbed_question_still_answered(engine: Engine) -> None:
await press(engine, "menu:kb_question")
r = await say(engine, "Иван Петров, 89161234567, что такое ПТСР?")
assert "ПТСР – это расстройство" in joined(r)
# --- free text in idle ------------------------------------------------------------------------------------
async def test_free_text_question_answered_from_guidelines(engine: Engine) -> None:
r = await say(engine, "помогает ли психотерапия при ПТСР?")
text = joined(r)
assert "В клинических рекомендациях Минздрава сказано:" in text
assert "Источник: КР ПТСР 2023" in text
@pytest.mark.parametrize("message", ["сегодня обычный день", "как дела?"])
async def test_free_text_without_answer_keeps_support(engine: Engine, message: str) -> None:
r = await say(engine, message)
assert joined(r) == SUPPORT
assert "Источник" not in joined(r)
async def test_drug_question_stays_with_instruction(engine: Engine) -> None:
# SPEC §7 п.5: the patient's own drug is answered only from its instruction, never from the guidelines.
r = await say(engine, "через сколько подействуют таблетки?")
text = joined(r)
assert text.startswith(load_med_qa_texts().intro)
assert "клинических рекомендациях" not in text
async def test_side_effect_question_goes_to_checkin(engine: Engine) -> None:
r = await say(engine, "меня тошнит от таблеток, это нормально?")
assert "клинических рекомендациях" not in joined(r)
async def test_acute_risk_never_reaches_knowledge_base(engine: Engine) -> None:
r = await say(engine, "зачем жить? хочу умереть")
assert any(x.kind == "crisis" for x in r)
assert "клинических рекомендациях" not in joined(r)
+323
View File
@@ -0,0 +1,323 @@
"""Demo adapters (#14): МИС mock, connect-code identity, ГРЛС excerpts, doctor notifier, registry setup."""
import json
from collections.abc import AsyncIterator
from datetime import UTC, date, datetime, timedelta
from pathlib import Path
import pytest
from app.core import clock
from app.core.types import ScrubbedText
from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Prescription as PrescriptionRow
from app.integrations import (
CompositeNotifier,
ConnectCodeIdentity,
GrlsExcerpts,
MisMock,
NotificationFeed,
setup,
)
from app.integrations import notify as notify_module
from app.integrations.base import (
EmptyPrescriptions,
MemoryNotifier,
MemoryOutbox,
NoDrugInfo,
RejectIdentity,
integrations,
)
from app.integrations.grls import normalize_inn
from app.integrations.notify import TelegramDoctorChat, format_message
from app.llm import med_qa as med_qa_module
from app.llm.gateway import LLMGateway, is_forbidden
from app.llm.med_qa import DOSING_SENTENCE, SECTIONS, med_qa
from app.llm.mock import MockTasks
MEM = "sqlite+aiosqlite:///:memory:"
TOKEN = "a" * 32
T0 = datetime(2026, 10, 1, 9, 0, tzinfo=UTC)
GRLS_DIR = Path(__file__).resolve().parents[1] / "data" / "meds" / "grls"
CORE_DRUGS = ("сертралин", "пароксетин", "эсциталопрам", "флуоксетин")
@pytest.fixture
async def dbs() -> AsyncIterator[None]:
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
try:
yield
finally:
await session.configure()
# ----------------------------------------------------------------------------------------------- setup
def test_setup_registers_demo_adapters() -> None:
assert isinstance(integrations.prescriptions, MisMock)
assert isinstance(integrations.identity, ConnectCodeIdentity)
assert isinstance(integrations.drug_info, GrlsExcerpts)
assert isinstance(integrations.notifier, CompositeNotifier)
# The outbox stays the in-memory queue the web console drains (Telegram wraps it at start).
assert isinstance(integrations.outbox, MemoryOutbox)
def test_setup_is_idempotent() -> None:
before = (integrations.prescriptions, integrations.identity, integrations.drug_info, integrations.notifier)
setup()
setup()
assert (integrations.prescriptions, integrations.identity, integrations.drug_info, integrations.notifier) == before
def test_setup_replaces_only_defaults(monkeypatch: pytest.MonkeyPatch) -> None:
fake_notifier = MemoryNotifier()
sink = type("Sink", (MemoryNotifier,), {})()
monkeypatch.setattr(integrations, "prescriptions", EmptyPrescriptions())
monkeypatch.setattr(integrations, "identity", RejectIdentity())
monkeypatch.setattr(integrations, "drug_info", NoDrugInfo())
monkeypatch.setattr(integrations, "notifier", fake_notifier)
setup(force=True)
assert isinstance(integrations.prescriptions, MisMock)
assert isinstance(integrations.notifier, CompositeNotifier)
monkeypatch.setattr(integrations, "notifier", sink)
monkeypatch.setattr("app.integrations._done", False)
setup()
assert integrations.notifier is sink # a swapped-in test sink is kept
# --------------------------------------------------------------------------------------------- mis_mock
async def test_mis_mock_catalogue_fallback() -> None:
mis = MisMock(use_db=False)
[rx] = await mis.get_prescriptions("tok-andrey")
assert (rx.drug, rx.dose, rx.schedule["times"]) == ("сертралин", "50 мг", ["08:00"])
assert rx.instructions == "утром после еды"
assert await mis.get_prescriptions("unknown") == []
async def test_mis_mock_catalogue_drugs_have_excerpts() -> None:
raw = json.loads((GRLS_DIR.parent / "mis_prescriptions.json").read_text(encoding="utf-8"))
known = set(GrlsExcerpts().known())
assert {rx["drug"] for p in raw["patients"] for rx in p["prescriptions"]} <= known
async def test_mis_mock_missing_catalogue_is_empty(tmp_path: Path) -> None:
assert await MisMock(tmp_path / "nope.json", use_db=False).get_prescriptions("tok-andrey") == []
async def test_mis_mock_prefers_db(dbs: None) -> None:
async with session.get_clinical_session() as s:
s.add(
PrescriptionRow(
token="tok-andrey",
drug="эсциталопрам",
dose="10 мг",
schedule={"times": ["09:00"], "instructions": "утром"},
start_date=date(2026, 10, 1),
)
)
[rx] = await MisMock().get_prescriptions("tok-andrey")
assert (rx.drug, rx.dose, rx.instructions, rx.start_date) == ("эсциталопрам", "10 мг", "утром", date(2026, 10, 1))
# No DB rows for this token -> catalogue.
[rx] = await MisMock().get_prescriptions("tok-sergey")
assert rx.drug == "пароксетин"
async def test_mis_mock_inside_caller_session_keeps_its_work(dbs: None) -> None:
# The dialog calls the adapter while its own clinical session holds flushed changes.
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-smirnova")
assert await MisMock().get_prescriptions(TOKEN) == []
async with session.get_clinical_session() as s:
assert await repo.get_profile(s, TOKEN) is not None
async def test_mis_mock_db_down_falls_back(tmp_path: Path) -> None:
await session.configure(clinical_url=f"sqlite+aiosqlite:///{tmp_path}/missing/dir/x.db")
try:
[rx] = await MisMock().get_prescriptions("tok-andrey")
finally:
await session.configure()
assert rx.drug == "сертралин"
# ------------------------------------------------------------------------------------------ connect code
async def _code(code: str = "DEMO-0001") -> None:
async with session.get_identity_session() as s:
await identity_repo.create_connect_code(s, code=code, token=TOKEN, doctor_id="doc-smirnova", now=T0)
async def test_connect_code_verifies_once(dbs: None, monkeypatch: pytest.MonkeyPatch) -> None:
await _code()
ident = ConnectCodeIdentity()
monkeypatch.setattr(clock, "now", lambda: T0)
person = await ident.verify(" DEMO-0001 ")
assert person is not None and (person.token, person.doctor_id) == (TOKEN, "doc-smirnova")
# Same connect flow (Telegram /start, then the engine's `new` state) re-checks the code: still valid.
monkeypatch.setattr(clock, "now", lambda: T0 + timedelta(minutes=1))
assert await ident.verify("DEMO-0001") == person
# Later the used code is rejected.
monkeypatch.setattr(clock, "now", lambda: T0 + timedelta(hours=1))
assert await ident.verify("DEMO-0001") is None
async def test_connect_code_unknown_or_empty(dbs: None) -> None:
assert await ConnectCodeIdentity().verify("NOPE") is None
assert await ConnectCodeIdentity().verify(" ") is None
async def test_connect_code_db_down(tmp_path: Path) -> None:
await session.configure(identity_url=f"sqlite+aiosqlite:///{tmp_path}/missing/dir/x.db")
try:
assert await ConnectCodeIdentity().verify("DEMO-0001") is None
finally:
await session.configure()
# ------------------------------------------------------------------------------------------------- ГРЛС
async def test_grls_core_drugs_have_all_sections() -> None:
grls = GrlsExcerpts()
for inn in CORE_DRUGS:
sections = await grls.sections(inn)
assert {s.section for s in sections} == set(SECTIONS), inn
assert all(s.inn == inn and s.source.startswith("ГРЛС, инструкция по медицинскому") for s in sections)
assert {"миртазапин", "празозин"} <= set(grls.known())
async def test_grls_texts_are_short_safe_and_dose_free() -> None:
grls = GrlsExcerpts()
for inn in grls.known():
for s in await grls.sections(inn):
sentences = [x for x in s.text.replace("! ", ". ").split(". ") if x]
assert 1 <= len(sentences) <= 4, (inn, s.section)
assert not is_forbidden(s.text), (inn, s.section)
if s.section != "administration":
assert not DOSING_SENTENCE.search(s.text), (inn, s.section)
assert "мг" not in s.text.split(), (inn, s.section)
def test_grls_files_carry_verification_header() -> None:
for path in GRLS_DIR.glob("*.yaml"):
assert "сверить с действующей инструкцией ГРЛС" in path.read_text(encoding="utf-8").splitlines()[0], path
async def test_grls_lookup_normalisation() -> None:
grls = GrlsExcerpts()
assert normalize_inn("Сертралин 50 мг") == "сертралин"
assert await grls.sections("Sertraline") == await grls.sections("сертралин 50 мг")
assert await grls.sections("аспирин") == []
async def test_grls_bad_files_are_skipped(tmp_path: Path) -> None:
(tmp_path / "bad.yaml").write_text("inn: [", encoding="utf-8")
(tmp_path / "wrong.yaml").write_text("inn: x\n", encoding="utf-8")
assert GrlsExcerpts(tmp_path).known() == []
assert await GrlsExcerpts(tmp_path / "missing").sections("сертралин") == []
# --------------------------------------------------------------- med_qa end-to-end on real sertraline data
@pytest.fixture
def mock_gateway(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(med_qa_module, "get_gateway", lambda: LLMGateway(MockTasks()))
async def test_med_qa_day2_nausea(mock_gateway: None) -> None:
answer = await med_qa("сертралин", ScrubbedText("подташнивает с утра"))
assert answer is not None and answer.section == "side_effects"
assert "тошнот" in answer.text.lower() and "1–2 недель" in answer.text
assert "Побочное действие" in answer.source
async def test_med_qa_day5_quit(mock_gateway: None) -> None:
answer = await med_qa("сертралин", ScrubbedText("от таблеток только хуже, брошу"), topic="quit_intent")
assert answer is not None and answer.section == "onset"
assert "2–4 недели" in answer.text
async def test_med_qa_day10_alcohol(mock_gateway: None) -> None:
answer = await med_qa("сертралин", ScrubbedText("без пива не могу уснуть"))
assert answer is not None and answer.section == "alcohol"
assert "алкогол" in answer.text.lower() and "сон" in answer.text
async def test_med_qa_other_drugs_answer_core_topics(mock_gateway: None) -> None:
for inn in CORE_DRUGS:
for question in ("тошнит после таблетки", "когда начнёт действовать", "можно ли выпить пива", "хочу бросить"):
answer = await med_qa(inn, ScrubbedText(question))
assert answer is not None, (inn, question)
assert "мг" not in answer.text
async def test_med_qa_dose_question_still_goes_to_doctor(mock_gateway: None) -> None:
assert await med_qa("сертралин", ScrubbedText("можно увеличить дозу?")) is None
# ------------------------------------------------------------------------------------------------ notify
class FakeChat:
def __init__(self, fail: bool = False) -> None:
self.sent: list[tuple[int, str]] = []
self.fail = fail
async def __call__(self, chat_id: int, text: str) -> None:
if self.fail:
raise RuntimeError("telegram down")
self.sent.append((chat_id, text))
async def test_feed_ids_are_monotonic_and_filterable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(clock, "now", lambda: T0)
notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=None, bot_token=""))
await notifier.send("doc-smirnova", "moderate", "alcohol_3d", TOKEN)
await notifier.send("doc-other", "high", "sleep_lt4_3n", "b" * 32)
await notifier.send("doc-smirnova", "acute", "phq9_item9", TOKEN)
items = notifier.recent()
assert [n.id for n in items] == [1, 2, 3] and items[0].ts == T0
assert [n.text for n in notifier.recent(since_id=1, doctor_id="doc-smirnova")] == ["phq9_item9"]
assert notifier.recent(since_id=3) == []
def test_feed_is_bounded() -> None:
feed = NotificationFeed(size=2)
for i in range(3):
feed.add("d", "info", f"r{i}", None, T0)
assert [n.id for n in feed.recent()] == [2, 3]
async def test_telegram_doctor_chat_message_has_no_pii() -> None:
chat = FakeChat()
notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=-100, bot_token="", sender=chat))
await notifier.send("doc-smirnova", "acute", "PHQ-9 п.9 = 2 — кризисный протокол", TOKEN)
[(chat_id, text)] = chat.sent
assert chat_id == -100
assert text == "[КРИТИЧНО] PHQ-9 п.9 = 2 — кризисный протокол\nПациент #aaaaaa"
assert TOKEN not in text
async def test_telegram_failure_is_swallowed() -> None:
notifier = CompositeNotifier(chat=TelegramDoctorChat(chat_id=1, bot_token="", sender=FakeChat(fail=True)))
await notifier.send("doc-smirnova", "high", "sleep_lt4_3n", TOKEN) # does not raise
assert len(notifier.recent()) == 1
def test_telegram_chat_disabled_without_settings() -> None:
assert not TelegramDoctorChat(chat_id=None, bot_token="x").enabled
assert not TelegramDoctorChat(chat_id=5, bot_token="").enabled
assert TelegramDoctorChat(chat_id=5, bot_token="123:abc").enabled
def test_format_message_trims_long_reason() -> None:
text = format_message("info", "x" * 500, None)
assert len(text.splitlines()[0]) <= notify_module.MAX_REASON_CHARS + len("[Инфо] ")
assert text.endswith("Пациент #—")
+199
View File
@@ -0,0 +1,199 @@
import json
import httpx
import pytest
from app.config import Settings
from app.core.privacy import pseudo_id, scrub
from app.core.types import ScrubbedText
from app.integrations.base import Prescription, integrations
from app.integrations.emias_record import emias_records
from app.llm import agent
from app.llm.agent.drug_safety import check_patient, find_drug
from app.llm.agent.providers import MockAgentProvider, OpenRouterProvider
from app.llm.agent.tools import ToolContext, run_tool
ANDREY = pseudo_id("900-000-013 34")
ANNA = pseudo_id("900-000-023 04")
SERGEY = pseudo_id("900-000-037 02")
class _Rx:
def __init__(self, drug: str) -> None:
self.drug = drug
async def get_prescriptions(self, token: str) -> list[Prescription]:
return [Prescription(drug=self.drug, dose="50 мг", instructions="утром после еды")]
@pytest.fixture
def sertraline(monkeypatch):
monkeypatch.setattr(integrations, "prescriptions", _Rx("сертралин"))
# ----------------------------------------------------------------------------------------------- drug safety
@pytest.mark.parametrize("name", ["сертралин", "сертралине", "Золофт", "сертралин 50 мг", "sertraline"])
def test_find_drug_forms(name):
assert find_drug(name).inn == "сертралин"
def test_find_drug_unknown():
assert find_drug("аспирин") is None
async def test_andrey_sertraline_findings(sertraline):
rec = await emias_records.get(ANDREY)
findings = check_patient(find_drug("сертралин"), rec)
kinds = {(f.kind, f.severity) for f in findings}
assert ("condition", "high") in kinds # ulcer history
assert any(f.kind == "interaction" and "ибупрофен" in f.text for f in findings)
async def test_ibuprofen_sees_current_ssri(sertraline):
rec = await emias_records.get(ANDREY)
findings = check_patient(find_drug("ибупрофен"), rec)
assert any("СИОЗС" in f.text and "сертралин" in f.text for f in findings)
async def test_pregnancy_and_triptan(monkeypatch):
monkeypatch.setattr(integrations, "prescriptions", _Rx("флуоксетин"))
anna = await emias_records.get(ANNA)
assert any(f.kind == "pregnancy" for f in check_patient(find_drug("флуоксетин"), anna))
monkeypatch.setattr(integrations, "prescriptions", _Rx("пароксетин"))
sergey = await emias_records.get(SERGEY)
assert any("Триптаны" in f.text for f in check_patient(find_drug("пароксетин"), sergey))
# ----------------------------------------------------------------------------------------------------- tools
async def test_record_tool_has_no_identifiers(sertraline):
out = await run_tool(ToolContext(token=ANDREY), "get_patient_record", {})
data = json.loads(out)
assert data["found"] and data["current_prescriptions"][0]["drug"] == "сертралин"
assert "900-000" not in out and "Андрей" not in out
async def test_search_tool_drops_doses():
out = json.loads(await run_tool(ToolContext(token=ANDREY), "search_guidelines", {"query": "дозы СИОЗС при ПТСР"}))
for r in out["results"]:
assert " мг" not in r["text"]
async def test_unknown_tool_is_json_error():
assert "error" in json.loads(await run_tool(ToolContext(token=ANDREY), "rm_rf", {}))
# ------------------------------------------------------------------------------------------------ agent loop
def _settings(**kw) -> Settings:
base = {"openrouter_api_key": "test", "chat_agent": "openrouter", "chat_agent_timeout_s": 5.0}
return Settings(_env_file=None, **{**base, **kw})
def _fake_openrouter(final_text: str, calls: list[dict]) -> tuple[OpenRouterProvider, list[dict]]:
sent: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
sent.append(body)
if len(sent) == 1:
msg = {"role": "assistant", "content": None, "tool_calls": calls}
else:
msg = {"role": "assistant", "content": final_text}
return httpx.Response(200, json={"choices": [{"message": msg}], "usage": {"total_tokens": 10}})
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
return OpenRouterProvider(_settings(), client=client), sent
_CALLS = [
{"id": "a", "type": "function", "function": {"name": "get_patient_record", "arguments": "{}"}},
{
"id": "b",
"type": "function",
"function": {"name": "check_drug_for_patient", "arguments": '{"drug": "ибупрофен"}'},
},
]
async def test_agent_openrouter_tool_loop(sertraline):
text = (
"В вашей карте отмечена язвенная болезнь, а ибупрофен с сертралином повышает риск кровотечений. "
"Обсудите с врачом."
)
provider, sent = _fake_openrouter(text, _CALLS)
ans = await agent.answer_chat(
ANDREY, scrub("Можно ибупрофен? Мой телефон 89161234567"), provider=provider, settings=_settings()
)
assert ans is not None and ans.text == text
assert ans.tools == ["get_patient_record", "check_drug_for_patient"]
assert any("ЕМИАС" in s for s in ans.sources)
first, second = sent
assert "89161234567" not in json.dumps(first, ensure_ascii=False) # scrubbed before the provider
tool_msgs = [m for m in second["messages"] if m["role"] == "tool"]
assert len(tool_msgs) == 2 and all("name" not in m for m in tool_msgs)
assert first["tools"] and first["model"]
async def test_forbidden_output_rejected(sertraline):
provider, _ = _fake_openrouter("Увеличьте дозу сертралина вдвое.", _CALLS)
assert await agent.answer_chat(ANDREY, scrub("что делать"), provider=provider, settings=_settings()) is None
async def test_provider_error_returns_none():
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(500)))
provider = OpenRouterProvider(_settings(), client=client)
assert await agent.answer_chat(ANDREY, scrub("вопрос"), provider=provider, settings=_settings()) is None
async def test_unscrubbed_input_never_sent():
provider, sent = _fake_openrouter("ok", [])
assert await agent.answer_chat(ANDREY, ScrubbedText("звоните 8 916 123-45-67"), provider=provider) is None
assert sent == []
async def test_mock_agent_answers_offline(sertraline):
ans = await agent.answer_chat(
ANDREY, scrub("Можно ли мне ибупрофен вместе с моими таблетками?"), provider=MockAgentProvider()
)
assert ans is not None and "кровотеч" in ans.text and ans.provider == "mock"
def test_build_provider_modes():
assert agent.build_provider(Settings(_env_file=None, chat_agent="auto")) is None
assert isinstance(agent.build_provider(_settings(chat_agent="auto")), OpenRouterProvider)
assert isinstance(agent.build_provider(Settings(_env_file=None, chat_agent="mock")), MockAgentProvider)
assert agent.build_provider(Settings(_env_file=None, chat_agent="openrouter")) is None # no key
assert agent.build_provider(Settings(_env_file=None, chat_agent="off")) is None
async def test_check_defaults_to_current_prescription(sertraline):
out = json.loads(await run_tool(ToolContext(token=ANDREY), "check_drug_for_patient", {"drug": "мои таблетки"}))
assert out["found"] and out["inn"] == "сертралин"
assert "лактоз" in out["intolerance_check"] and "нет" in out["intolerance_check"]
assert any("Алкоголь" in t for t in out["general_interactions"])
async def test_empty_final_answer_is_nudged(sertraline):
replies = iter(
[
{"role": "assistant", "content": None, "tool_calls": _CALLS},
{"role": "assistant", "content": ""},
{"role": "assistant", "content": "Алкоголь во время лечения не рекомендуется. Обсудите с врачом."},
]
)
sent = []
def handler(request):
sent.append(json.loads(request.content))
return httpx.Response(200, json={"choices": [{"message": next(replies)}]})
provider = OpenRouterProvider(_settings(), client=httpx.AsyncClient(transport=httpx.MockTransport(handler)))
ans = await agent.answer_chat(ANDREY, scrub("пиво можно?"), provider=provider, settings=_settings())
assert ans is not None and "Алкоголь" in ans.text
assert sent[0]["tool_choice"] == "required" and "tool_choice" not in sent[1]
assert "сертралин" in sent[0]["messages"][0]["content"] # patient brief in the system prompt
+4 -3
View File
@@ -3,7 +3,7 @@
import pytest
from app.core.types import ScrubbedText
from app.integrations.base import DrugSection
from app.integrations.base import DrugSection, NoDrugInfo, integrations
from app.llm import med_qa as med_qa_module
from app.llm.gateway import LLMGateway, is_forbidden
from app.llm.med_qa import MedAnswer, adds_content, detect_topic, fallback_text, med_qa, select_excerpt
@@ -173,8 +173,9 @@ async def test_unknown_drug_and_unknown_topic(info: FakeDrugInfo, mock_gateway:
assert await med_qa("сертралин", S("какая погода"), drug_info=info) is None
async def test_default_registry_has_no_data(mock_gateway: None) -> None:
# Before lane B (#14) registers real excerpts the default NoDrugInfo returns nothing.
async def test_empty_registry_has_no_data(mock_gateway: None, monkeypatch: pytest.MonkeyPatch) -> None:
# Without excerpts (base.py default NoDrugInfo; #14 registers real ones at import) nothing is answered.
monkeypatch.setattr(integrations, "drug_info", NoDrugInfo())
assert await med_qa("сертралин", S("тошнит")) is None
+35
View File
@@ -307,3 +307,38 @@ async def test_llm_rephrasing_kept_when_faithful(monkeypatch: pytest.MonkeyPatch
reply = await answer(S(q), audience="patient")
assert reply is not None and reply.phrased_by_llm
assert simplified in reply.text and reply.excerpt == excerpt # the verbatim quote is kept for audit
# --- regressions from the post-merge audit (#62) -------------------------------------------------------
def test_paragraph_flowing_to_next_page_cites_both_pages() -> None:
"""The converter glues a paragraph across a page break and marks the join with ⟦стр. N⟧."""
chunks = load_chunks()
assert not any("⟦" in c.text for c in chunks) # markers never reach the text
# KR PTSD: the comment on antipsychotics starts on p.22 and continues on p.23
chunk = next(c for c in chunks if "Препараты могут применяться в комбинированной терапии" in c.text)
assert chunk.page_start <= 23 <= chunk.page_end
async def test_scrub_placeholders_do_not_break_answers() -> None:
plain = await answer(S("что такое ПТСР"), audience="patient")
scrubbed = await answer(S("[NAME], [PHONE], что такое ПТСР"), audience="patient")
assert plain is not None and scrubbed is not None
assert scrubbed.excerpt == plain.excerpt
assert tokens("[NAME] сон") == ["сон"]
def test_question_naming_a_disease_prefers_its_guideline() -> None:
index = get_index()
assert index.cued_docs("ТМС при депрессии") == {"kr_depression"}
assert index.cued_docs("кошмары после войны") == {"kr_ptsd"}
assert index.cued_docs("депрессия и ПТСР") == set() # both named: no preference
assert index.cued_docs("когда подействуют антидепрессанты") == set() # a drug class, not the disease
hit = index.search("транскраниальная магнитная стимуляция при депрессии", audience="patient")[0]
assert hit.chunk.doc.id == "kr_depression"
async def test_answer_follows_disease_cue() -> None:
reply = await answer(S("транскраниальная магнитная стимуляция при депрессии"), audience="patient")
assert reply is not None and reply.citations[0].doc == "kr_depression"
+333
View File
@@ -0,0 +1,333 @@
"""Demo seed (#18): idempotent cabinet, Andrey's modes, colours of the 7 patients, and the console scenario
`data/synthetic/andrey.jsonl` replayed against the real dialog engine + demo scheduler + monitoring (sqlite)."""
import re
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from typing import Any
import httpx
import pytest
from sqlalchemy import func, select
from app.config import Settings
from app.core import clock
from app.db import identity_repo, repo, session
from app.db.audit import AuditLog
from app.db.clinical import ClinicalBase, Dose, SafetyPlan
from app.db.identity import ConnectCode, IdentityBase
from app.dialog import contracts as dialog_contracts
from app.dialog.contracts import Button, Event, Reply
from app.dialog.engine import Engine
from app.integrations.base import MemoryOutbox, integrations
from app.llm import med_qa as med_qa_module
from app.llm.gateway import LLMGateway
from app.llm.mock import MockTasks
from app.scheduler import jobs
from app.services import monitoring as _monitoring # noqa: F401 (registers the real MonitoringService)
from scripts import seed_demo
from scripts.seed_demo import ANDREY_COLORS, ANDREY_DAYS, OTHERS, SeedResult, seed
MEM = "sqlite+aiosqlite:///:memory:"
NOW = datetime(2026, 10, 5, 14, 0) # virtual "now" of the seed (Europe/Moscow)
@pytest.fixture
async def dbs(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[None]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
clock.set_now(NOW)
await session.configure(identity_url=MEM, clinical_url=MEM)
yield
clock.reset()
await session.configure()
async def counts() -> dict[str, int]:
out: dict[str, int] = {}
for base, factory in ((ClinicalBase, session.get_clinical_session), (IdentityBase, session.get_identity_session)):
async with factory() as s:
for table in base.metadata.sorted_tables:
out[table.name] = await s.scalar(select(func.count()).select_from(table)) or 0
return out
async def open_codes(token: str, *, acked: bool = False) -> set[str]:
async with session.get_clinical_session() as s:
alerts = await repo.list_alerts(s, token=token)
return {a.code for a in alerts if (a.acked_at is not None) == acked}
async def color(token: str) -> str | None:
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token)
return profile.status_color if profile else None
# ----------------------------------------------------------------------------------------------- seed
async def test_seed_is_idempotent(dbs: None) -> None:
await seed(12)
first = await counts()
await seed(12)
assert await counts() == first
assert first["patient_profiles"] == first["patients"] == 8 and first["doctors"] == 1
await seed(0)
again = await counts()
await seed(0)
assert await counts() == again
async def test_reset_recreates_tables_and_zeroes_clock(dbs: None) -> None:
await seed(5)
clock.advance(days=2)
assert await seed_demo.reset(None) is None
assert clock.offset() == timedelta(0)
assert all(v == 0 for k, v in (await counts()).items() if k != "clock_state")
result = await seed_demo.reset() # what POST /demo/reset calls: reset + Andrey on day 0
assert result is not None and result.colors[seed_demo.ANDREY_NAME] == "green"
assert (await counts())["patient_profiles"] == 8
async def test_andrey_day0_is_ready_for_the_live_demo(dbs: None) -> None:
result = await seed(0)
token = result.andrey_token
assert re.fullmatch(r"[A-HJ-NP-Z2-9]{6}", result.connect_code)
async with session.get_identity_session() as s:
patient = await identity_repo.get_patient(s, token)
doctor = await identity_repo.get_doctor(s, seed_demo.DOCTOR_ID)
code = await s.get(ConnectCode, result.connect_code)
assert patient is not None and patient.consent_at is None and patient.display_name == "Андрей С."
assert doctor is not None and doctor.on_call and doctor.full_name == "Смирнова Е. А."
assert code is not None and code.token == token and code.used_at is None
assert await identity_repo.redeem_connect_code(s, code=result.connect_code, now=clock.now()) == token
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token)
assert profile is not None
assert profile.modules == ["veteran", "depression"] and profile.sex == "m"
assert NOW.year - (profile.birth_year or 0) == 34
assert profile.therapy_start == NOW.date() and profile.next_visit == NOW.date() + timedelta(days=28)
assert await repo.get_dialog_state(s, token) is None # consent and onboarding happen live
assert not await repo.list_checkins(s, token) and not await repo.list_scale_results(s, token)
plan = await s.get(SafetyPlan, token)
assert plan is not None and len(plan.steps) == 6
assert result.colors["Андрей С."] == "green"
@pytest.mark.parametrize("day", [3, 4, 5, 6, 9, 10])
async def test_andrey_colour_follows_the_persona(dbs: None, day: int) -> None:
result = await seed(day)
assert result.colors["Андрей С."] == ANDREY_COLORS[day - 1]
async def test_andrey_day12_yellow(dbs: None) -> None:
result = await seed(12)
token = result.andrey_token
assert result.colors["Андрей С."] == "yellow"
assert await open_codes(token) == {"sleep_lt4_3n", "alcohol_3d"}
assert await open_codes(token, acked=True) == {"side_effect_3d", "quit_intent"} # doctor called on day 6
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token)
assert profile is not None and profile.therapy_start == NOW.date() - timedelta(days=12)
rows = await repo.list_checkins(s, token)
assert [(r.mood, r.sleep_bucket, r.alcohol_bucket) for r in rows] == [
(ANDREY_DAYS[n].mood, ANDREY_DAYS[n].sleep, ANDREY_DAYS[n].alcohol) for n in range(1, 13)
]
phq = [(r.total, r.item9, r.scheduled) for r in await repo.list_scale_results(s, token, "phq9")]
assert phq == [(14, 0, True), (13, 0, True)]
totals = {r.scale: r.total for r in await repo.list_scale_results(s, token) if r.ts.date() < rows[0].day}
assert totals == {"pcptsd5": 4, "pcl5": 46, "phq9": 14, "auditc": 3}
doses = (await s.scalars(select(Dose).where(Dose.token == token).order_by(Dose.planned_at))).all()
assert [d.status for d in doses] == ["skipped" if n == 5 else "taken" for n in range(1, 13)]
state = await repo.get_dialog_state(s, token)
assert state is not None and state.state == "idle" and state.data["consent"] is True
async with session.get_identity_session() as s:
patient = await identity_repo.get_patient(s, token)
assert patient is not None and patient.consent_at is not None
async def test_andrey_day13_red_crisis(dbs: None) -> None:
result = await seed(13)
token = result.andrey_token
assert result.colors["Андрей С."] == "red"
async with session.get_clinical_session() as s:
acute = [a.code for a in await repo.list_alerts(s, token=token, open_only=True) if a.level == "acute"]
assert acute == ["phq9_item9"]
last = (await repo.list_scale_results(s, token, "phq9"))[-1]
assert (last.total, last.item9, last.scheduled) == (18, 2, False)
state = await repo.get_dialog_state(s, token)
assert state is not None and state.state == "crisis" and state.data["crisis"]["open"] is True
audits = (await s.scalars(select(AuditLog.action).where(AuditLog.token == token))).all()
assert "crisis_acute" in audits and "ack_alert" in audits
async def test_other_patients_match_the_persona_table(dbs: None) -> None:
result = await seed(0)
expected_codes = {
"Дмитрий В.": {"sleep_lt4_3n"},
"Сергей П.": {"missed_doses_7d"},
"Николай Ф.": {"alcohol_3d"},
"Анна Л.": {"side_effect_3d"},
"Ирина К.": set(),
"Ольга Н.": set(),
"Павел Р.": set(),
}
for spec in OTHERS:
token = result.tokens[spec.name]
assert result.colors[spec.name] == spec.color, spec.name
assert await open_codes(token) == expected_codes[spec.name], spec.name
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token)
assert profile is not None and profile.therapy_start is not None
assert (NOW.date() - profile.therapy_start).days == spec.therapy_day
assert NOW.year - (profile.birth_year or 0) == spec.age
assert profile.next_visit is not None and profile.next_visit > NOW.date()
assert await repo.get_dialog_state(s, token) is None # the scheduler leaves them alone
assert await open_codes(result.tokens["Ольга Н."], acked=True) == {"side_effect_3d"} # «тошнота прошла»
async with session.get_clinical_session() as s:
anna = await repo.get_profile(s, result.tokens["Анна Л."])
assert anna is not None and anna.enhanced_monitoring # age < 25, first weeks of therapy
pavel = await repo.list_scale_results(s, result.tokens["Павел Р."], "phq9")
irina = await repo.list_scale_results(s, result.tokens["Ирина К."], "phq9")
assert pavel[-1].total == 4 and [r.total for r in irina] == [16, 13, 9]
async def test_seed_is_deterministic_except_connect_code(dbs: None) -> None:
async def snapshot(result: SeedResult) -> list[Any]:
async with session.get_clinical_session() as s:
out = []
for token in result.tokens.values():
out += [(c.day, c.mood, c.sleep_bucket, c.alcohol_bucket) for c in await repo.list_checkins(s, token)]
out += [(a.code, a.ts) for a in await repo.list_alerts(s, token=token)]
return out
first = await snapshot(await seed(12))
assert await snapshot(await seed(12)) == first
# ----------------------------------------------------------------------------------------------- scenario file
def test_scenario_file_format_and_in_sync_with_config() -> None:
lines = seed_demo.load_scenario()
assert lines == seed_demo.scenario_lines(), "regenerate: python -m scripts.seed_demo --write-scenario"
assert [line["day"] for line in lines] == list(range(0, 14))
for line in lines:
assert set(line) == {"day", "note", "actions"} and line["note"]
for action in line["actions"]:
assert len(action) == 1 and set(action) <= seed_demo.ALLOWED_ACTIONS, action
((key, value),) = action.items()
assert isinstance(value, str) and value
if key == "advance_to":
assert re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", value)
if key == "expect_color":
assert value in {"green", "yellow", "red"}
assert lines[13]["actions"][-1] == {"expect_color": "red"}
class Console:
"""What the demo console (#17) does with a scenario line, in-process: the demo clock + scheduler instead of
`/demo/clock/advance`, the engine instead of `/dialog/{token}/events`, the outbox instead of its poll."""
def __init__(self, token: str, engine: Engine, outbox: MemoryOutbox) -> None:
self.token, self.engine, self.outbox = token, engine, outbox
self.batch: list[Reply] = []
async def advance_to(self, hhmm: str) -> None:
now = clock.now()
hh, mm = map(int, hhmm.split(":"))
target = now.replace(hour=hh, minute=mm, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
clock.set_now(target)
await jobs.run_until_now()
self.batch = self.outbox.drain(self.token)
def resolve(self, label: str) -> Button:
buttons = [b for r in self.batch for row in r.buttons for b in row]
hits = [b for b in buttons if label in b.label]
where = f"{clock.now():%d %H:%M}: {[r.text[:50] for r in self.batch]}"
assert hits, f"no button «{label}» in {[b.label for b in buttons]} at {where}"
# Unambiguous for any console strategy (first substring hit or exact match).
assert hits[0].label == label and len({b.data for b in buttons if b.label == label}) == 1, label
return hits[0]
async def press(self, label: str) -> None:
button = self.resolve(label)
self.batch = await self.engine.handle(self.token, Event(type="button", button=button.data))
async def text(self, text: str) -> None:
self.batch = await self.engine.handle(self.token, Event(type="text", text=text))
async def ack_all(token: str) -> None:
"""The presenter acknowledges Andrey's alerts on the dashboard (day 6 note)."""
async with session.get_clinical_session() as s:
for a in await repo.list_alerts(s, token=token, open_only=True):
await repo.ack_alert(s, alert_id=a.id, doctor_id=seed_demo.DOCTOR_ID, ts=clock.now())
await _monitoring.refresh_status(s, token)
async def test_scenario_replays_through_the_real_dialog(dbs: None, monkeypatch: pytest.MonkeyPatch) -> None:
gateway = LLMGateway(MockTasks())
engine = Engine(gateway=gateway)
outbox = MemoryOutbox()
monkeypatch.setattr(integrations, "outbox", outbox)
monkeypatch.setattr(med_qa_module, "get_gateway", lambda: gateway)
previous = dialog_contracts._factory
dialog_contracts.register_engine(lambda: engine) # the scheduler delivers through the same engine
try:
result = await seed(0)
token = result.andrey_token
console = Console(token, engine, outbox)
colors: dict[int, str | None] = {}
for line in seed_demo.load_scenario():
day = line["day"]
for action in line["actions"]:
((key, value),) = action.items()
if day == 6 and action == {"advance_to": "20:00"}:
await ack_all(token) # «до вечернего чек-ина подтвердите алерты»
if key == "expect_color":
assert await color(token) == value, f"day {day}"
else:
await getattr(console, key)(value)
colors[day] = await color(token)
assert (clock.today() - NOW.date()).days == day
finally:
dialog_contracts.register_engine(previous)
assert [colors[d] for d in range(1, 14)] == ANDREY_COLORS
assert any(r.kind == "crisis" for r in console.batch) and any(r.kind == "safety_plan" for r in console.batch)
async with session.get_clinical_session() as s:
rows = {(c.day - NOW.date()).days: c for c in await repo.list_checkins(s, token)}
for n, d in ANDREY_DAYS.items():
c = rows[n]
assert (c.mood, c.med_taken, c.sleep_bucket, c.nightmares, c.alcohol_bucket) == (
d.mood,
d.med_taken,
d.sleep,
d.nightmares,
d.alcohol,
), n
phq = [(r.total, r.item9, r.scheduled) for r in await repo.list_scale_results(s, token, "phq9")]
assert phq == [(14, 0, True), (13, 0, True), (18, 2, False)]
state = await repo.get_dialog_state(s, token)
assert state is not None and state.state == "crisis"
# The 7 background patients are not touched by 13 days of the demo clock.
for spec in OTHERS:
assert await color(result.tokens[spec.name]) == spec.color, spec.name
async def test_api_serves_the_seeded_cabinet(dbs: None) -> None:
from app.main import app
result = await seed(13)
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
rows = (await c.get(f"/api/v1/doctors/{seed_demo.DOCTOR_ID}/patients")).json()
personas = (await c.get("/api/v1/demo/personas")).json()
card = await c.get(f"/api/v1/patients/{result.andrey_token}", params={"doctor_id": seed_demo.DOCTOR_ID})
assert len(rows) == 8 and rows[0]["display_name"] == "Андрей С." and rows[0]["status_color"] == "red"
assert {r["display_name"]: r["status_color"] for r in rows} == result.colors
assert personas[0]["token"] == result.andrey_token and personas[0]["therapy_day"] == 13
assert personas[0]["scenario"] == "data/synthetic/andrey.jsonl"
assert card.status_code == 200 and len(card.json()["safety_plan"]) == 6