feat(bot): add doctor summary built from structured card data
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>
This commit is contained in:
2026-10-03 15:33:37 +03:00
co-authored by Claude Opus 5.5
parent df1956c493
commit 49d5f8a4e5
3 changed files with 650 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
"""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"]