Merge origin/dev into feat/rag-clinical-guidelines
CI / ruff · pytest (pull_request) Successful in 2m9s
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:
@@ -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()
|
||||
@@ -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("") == ""
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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 "<script>" 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 "<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] == ["Пациенты", "Подключить пациента"]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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("Пациент #—")
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user