feat(bot): add doctor summary built from structured card data
CI / ruff · pytest (pull_request) Successful in 24s
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:
@@ -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"]
|
||||
Reference in New Issue
Block a user