CI / ruff · pytest (pull_request) Successful in 24s
build_structured() aggregates a PatientCard into scores, flags and adherence (no patient text, no names); build_summary() sends it through the LLM gateway, falls back to a config-driven template, keeps an open acute alert first and caches per (token, provider, data hash). Refs #19 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
7.1 KiB
Python
175 lines
7.1 KiB
Python
"""Doctor summary (#19): deterministic aggregation, template, LLM path, cache."""
|
||
|
||
import json
|
||
from datetime import timedelta
|
||
|
||
import pytest
|
||
|
||
from app.api import fixtures
|
||
from app.api.schemas import DiscontinuationRisk, PatientCard, PatientListItem
|
||
from app.core.types import ScrubbedText
|
||
from app.llm import summary
|
||
from app.llm.gateway import LLMGateway, is_forbidden
|
||
from app.llm.mock import MockTasks
|
||
from app.llm.summary import build_structured, build_summary, load_template, render_template
|
||
|
||
|
||
class SpyTasks(MockTasks):
|
||
"""Mock tasks that count summary calls and can return a canned LLM answer."""
|
||
|
||
name = "spy"
|
||
|
||
def __init__(self, reply: str | None = None) -> None:
|
||
self.reply = reply
|
||
self.calls: list[str] = []
|
||
|
||
async def summarize_for_doctor(self, structured: ScrubbedText, base_text: str) -> str | None:
|
||
self.calls.append(structured)
|
||
return self.reply if self.reply is not None else base_text
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _clean_cache() -> None:
|
||
summary.clear_cache()
|
||
|
||
|
||
def _use(monkeypatch: pytest.MonkeyPatch, tasks: SpyTasks) -> SpyTasks:
|
||
gw = LLMGateway(tasks)
|
||
monkeypatch.setattr(summary, "get_gateway", lambda: gw)
|
||
return tasks
|
||
|
||
|
||
def _empty_card(drug: str | None = None) -> PatientCard:
|
||
patient = PatientListItem(
|
||
token="tok-x",
|
||
display_name="Тест Т.",
|
||
age=30,
|
||
modules=["veteran"],
|
||
drug=drug,
|
||
therapy_day=1,
|
||
status_color="grey",
|
||
discontinuation_risk=DiscontinuationRisk(score=0, level="low"),
|
||
)
|
||
return PatientCard(patient=patient, prescriptions=[], series=[], doses=[], alerts=[])
|
||
|
||
|
||
def test_structured_andrey_numbers() -> None:
|
||
s = build_structured(fixtures.andrey_card())
|
||
assert s["therapy_day"] == 13 and s["drug"] == "сертралин 50 мг"
|
||
phq9 = s["scales"]["phq9"]
|
||
# Baseline before the 14-day window is kept as the reference point.
|
||
assert phq9["values"] == [14, 13, 18]
|
||
assert phq9["therapy_days"] == [-2, 7, 13]
|
||
assert (phq9["delta"], phq9["delta_prev"]) == (4, 5)
|
||
assert phq9["last_unscheduled"] is True and phq9["unscheduled_count"] == 1
|
||
assert s["scales"]["pcl5"]["values"] == [46, 44]
|
||
assert s["mood"] == {"days": 13, "avg": 3.5, "last3": 2.7, "min": 2}
|
||
assert s["sleep"]["nights_lt4"] == 4 and s["sleep"]["max_streak_lt4"] == 3
|
||
assert s["alcohol"]["days"] == 4 and s["alcohol"]["heavy_days"] == 2
|
||
assert s["adherence"]["taken"] == 12 and s["adherence"]["planned"] == 13
|
||
assert s["adherence"]["missed_days"] == [5]
|
||
assert s["events"] == {"crisis": [13], "quit_intent": [5], "side_effects": []}
|
||
# The quit_intent alert is acked, so only three remain open.
|
||
assert s["open_alerts"] == {"acute": 1, "moderate": 2}
|
||
assert s["acute_open"] == [{"code": "phq9_item9", "therapy_day": 13}]
|
||
assert s["discontinuation_risk"]["score"] == 55
|
||
topics = load_template().topics
|
||
assert s["discuss"][0] == topics["safety"]
|
||
assert topics["quit"] in s["discuss"] and topics["sleep"] in s["discuss"]
|
||
|
||
|
||
def test_period_window_limits_daily_data() -> None:
|
||
s = build_structured(fixtures.andrey_card(), period_days=3)
|
||
assert s["mood"]["days"] == 3
|
||
assert s["adherence"]["planned"] == 3
|
||
assert s["events"]["quit_intent"] == []
|
||
# PHQ-9: the day-13 value plus the day-7 measurement as reference.
|
||
assert s["scales"]["phq9"]["values"] == [13, 18]
|
||
|
||
|
||
def test_no_identifiers_or_free_text_in_structured() -> None:
|
||
card = fixtures.andrey_card()
|
||
dump = json.dumps(build_structured(card), ensure_ascii=False)
|
||
assert "Андрей" not in dump and card.patient.token not in dump
|
||
assert "reason" not in dump and "ружьё" not in dump # alert reasons / safety plan are not used
|
||
assert "пациент" in dump
|
||
|
||
|
||
def test_template_safety_first_and_clean() -> None:
|
||
text = render_template(build_structured(fixtures.andrey_card()))
|
||
lines = text.splitlines()
|
||
assert 3 <= len(lines) <= 5
|
||
assert lines[0].startswith("Безопасность") and "PHQ-9 п.9" in lines[0]
|
||
assert "14 → 13 → 18" in text and "внеплановый" in text
|
||
assert "12/13" in text
|
||
assert lines[-1].startswith("Что обсудить на визите")
|
||
assert "Андрей" not in text
|
||
assert not is_forbidden(text)
|
||
|
||
|
||
def test_template_without_acute_lists_open_alerts() -> None:
|
||
card = fixtures.andrey_card()
|
||
card.alerts = [a for a in card.alerts if a.level != "acute"]
|
||
text = render_template(build_structured(card))
|
||
assert text.splitlines()[0].startswith("Открытые алерты")
|
||
|
||
|
||
async def test_mock_uses_template(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
_use(monkeypatch, SpyTasks())
|
||
card = fixtures.andrey_card()
|
||
assert await build_summary(card) == render_template(build_structured(card))
|
||
|
||
|
||
async def test_llm_text_used_and_safety_prepended(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
_use(monkeypatch, SpyTasks(reply="PHQ-9 вырос до 18, сон хуже, алкоголь 4 дня."))
|
||
text = await build_summary(fixtures.andrey_card())
|
||
lines = text.splitlines()
|
||
assert lines[0].startswith("Безопасность")
|
||
assert lines[1] == "PHQ-9 вырос до 18, сон хуже, алкоголь 4 дня."
|
||
|
||
|
||
async def test_forbidden_llm_text_falls_back(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
_use(monkeypatch, SpyTasks(reply="У вас депрессия, увеличьте дозу."))
|
||
card = fixtures.andrey_card()
|
||
assert await build_summary(card) == render_template(build_structured(card))
|
||
|
||
|
||
async def test_cache_avoids_second_provider_call(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
spy = _use(monkeypatch, SpyTasks())
|
||
card = fixtures.andrey_card()
|
||
first = await build_summary(card)
|
||
assert await build_summary(fixtures.andrey_card()) == first
|
||
assert len(spy.calls) == 1
|
||
# New data -> new fingerprint -> provider is called again.
|
||
card.doses[-1].status = "missed"
|
||
await build_summary(card)
|
||
assert len(spy.calls) == 2
|
||
sent = json.loads(spy.calls[0])
|
||
assert "Андрей" not in spy.calls[0] and sent["subject"] == "пациент"
|
||
|
||
|
||
async def test_empty_card(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
_use(monkeypatch, SpyTasks())
|
||
card = _empty_card()
|
||
s = build_structured(card)
|
||
assert s["scales"] == {} and s["adherence"]["planned"] == 0 and s["discuss"] == []
|
||
assert await build_summary(card) == load_template().empty
|
||
text = await build_summary(_empty_card(drug="сертралин 50 мг"))
|
||
assert "сертралин 50 мг" in text
|
||
|
||
|
||
def test_explicit_today_shifts_window() -> None:
|
||
card = fixtures.andrey_card()
|
||
s = build_structured(card, today=fixtures.TODAY + timedelta(days=30))
|
||
assert s["mood"]["days"] == 0 and s["adherence"]["planned"] == 0
|
||
assert s["open_alerts"]["acute"] == 1 # open alerts stay relevant outside the window
|
||
|
||
|
||
def test_list_level_card_uses_latest_values() -> None:
|
||
card = fixtures.card("tok-sergey")
|
||
assert card is not None
|
||
s = build_structured(card)
|
||
assert s["scales_latest"] == {"phq9": 12, "pcl5": 41}
|
||
assert s["open_alerts"] == {"moderate": 1}
|
||
assert load_template().topics["adherence"] in s["discuss"]
|