diff --git a/app/llm/summary.py b/app/llm/summary.py new file mode 100644 index 0000000..5d1964e --- /dev/null +++ b/app/llm/summary.py @@ -0,0 +1,387 @@ +"""Doctor-facing summary of a patient's dynamics (#19, SPEC §7 item 4). + +Two steps, so that the LLM never sees anything but numbers and flags: + +1. `build_structured(card)` — deterministic aggregation of the `PatientCard` (frozen API schema) into a small + JSON-able dict: scale trajectories, mood/sleep/alcohol counters, dose adherence, events derived from alert + codes, open alerts, discontinuation risk and the list of topics for the visit. No free text written by the + patient, no name, no token — the patient is just "пациент". +2. `build_summary(card)` — sends that dict through `app.llm.gateway.summarize_for_doctor` (GigaChat or mock) + and returns 3–5 Russian lines. If the provider is offline / mock / its answer was filtered, the gateway + hands back its generic label dump; we replace it with our own template (`config/texts/summary.yaml`). + +The summary states facts and lists topics; it never diagnoses or advises on doses — the doctor decides. +Lane B calls `build_summary` from the patient-card endpoint and puts the result into `PatientCard.summary`. +""" + +import hashlib +import json +from collections import OrderedDict +from collections.abc import Iterable +from datetime import date, timedelta +from functools import lru_cache +from pathlib import Path +from typing import Any + +import yaml +from pydantic import BaseModel + +from app.api.schemas import PatientCard, SeriesPoint +from app.config import get_settings +from app.llm.gateway import get_gateway, is_forbidden +from app.llm.texts import render_summary + +SCALE_METRICS = ("phq9", "pcl5", "gad7", "auditc", "dar5") +# Alert codes (rule ids from config/rules.yaml) that mean suicide/safety risk; any `acute` alert counts too. +CRISIS_CODES = frozenset({"phq9_item9", "cssrs_high", "cssrs_moderate", "weapon_access_risk"}) +QUIT_CODES = frozenset({"quit_intent"}) +SIDE_EFFECT_PREFIX = "side_effect" +SLEEP_SHORT_HOURS = 4.0 # "sleep < 4 h" night, same cut-off as rule sleep_lt4_3n +LEVEL_ORDER = ("acute", "high", "moderate", "info") +CACHE_SIZE = 256 + + +class SummaryThresholds(BaseModel): + phq9_rise: int = 5 + pcl5_rise: int = 10 + gad7_rise: int = 5 + sleep_nights: int = 3 + alcohol_days: int = 3 + mood_low: float = 3 + adherence_min: float = 0.8 + max_topics: int = 5 + + +class SummaryTemplate(BaseModel): + empty: str + thresholds: SummaryThresholds = SummaryThresholds() + metrics: dict[str, str] + levels: dict[str, str] + risk_levels: dict[str, str] + codes: dict[str, str] + lines: dict[str, str] + topics: dict[str, str] + + +@lru_cache +def load_template(path: Path | None = None) -> SummaryTemplate: + path = path or get_settings().config_dir / "texts" / "summary.yaml" + return SummaryTemplate.model_validate(yaml.safe_load(path.read_text(encoding="utf-8"))) + + +# --- aggregation ---------------------------------------------------------------------------------------- + + +def _reference_day(card: PatientCard) -> date | None: + """'Today' of the card = the latest date present in its data (no wall clock in business logic).""" + days = [p.day for p in card.series] + days += [d.planned_at.date() for d in card.doses if d.status != "pending"] + days += [a.ts.date() for a in card.alerts] + return max(days) if days else None + + +def _max_streak(days: Iterable[date]) -> int: + """Longest run of consecutive calendar days (rules fire on '3 days in a row', so we report the run too).""" + best = run = 0 + prev: date | None = None + for d in sorted(set(days)): + run = run + 1 if prev is not None and d - prev == timedelta(days=1) else 1 + best = max(best, run) + prev = d + return best + + +def _daily(points: list[SeriesPoint], metric: str, since: date, until: date) -> dict[date, float]: + """One value per day inside [since, until]; if a day has several entries the latest one wins.""" + return {p.day: p.value for p in points if p.metric == metric and since <= p.day <= until} + + +def _num(value: float) -> int | float: + """Scale totals are integers; keep them as int so the JSON/LLM shows '14', not '14.0'.""" + return int(value) if float(value).is_integer() else round(value, 1) + + +def _scale_trajectory( + points: list[SeriesPoint], metric: str, since: date, until: date, day_no: Any +) -> dict[str, Any] | None: + """Trajectory of one scale inside the window, anchored to the last measurement BEFORE the window. + + Scales are taken every 1–2 weeks, so a 14-day window often holds only one or two values; the previous + measurement (usually the baseline at the start of therapy) is the reference the doctor compares against. + `delta` = last − first (change vs. reference), `delta_prev` = last − previous (what rule *_rise checks). + """ + ordered = sorted((p for p in points if p.metric == metric and p.day <= until), key=lambda p: p.day) + inside = [p for p in ordered if p.day >= since] + if not inside: + return None + before = [p for p in ordered if p.day < since] + traj = ([before[-1]] if before else []) + inside + values = [_num(p.value) for p in traj] + return { + "values": values, + "therapy_days": [day_no(p.day) for p in traj], + "first": values[0], + "last": values[-1], + "min": min(values), + "max": max(values), + "delta": _num(values[-1] - values[0]), + "delta_prev": _num(values[-1] - values[-2]) if len(values) > 1 else 0, + "last_unscheduled": not traj[-1].scheduled, + "unscheduled_count": sum(1 for p in inside if not p.scheduled), + } + + +def _discuss_topics(s: dict[str, Any], th: SummaryThresholds, tpl: SummaryTemplate) -> list[str]: + """Topics for the visit, in clinical priority order: safety → symptom rise → therapy → daily patterns. + + These are flags for the conversation, not recommendations; capped so the line stays readable. + """ + topics: list[str] = [] + if s["acute_open"] or s["events"]["crisis"]: + topics.append(tpl.topics["safety"]) + rise_limits = {"phq9": th.phq9_rise, "pcl5": th.pcl5_rise, "gad7": th.gad7_rise} + for metric, limit in rise_limits.items(): + sc = s["scales"].get(metric) + # Rise vs. reference or vs. the previous measurement (the latter is exactly rule phq9_rise/pcl5_rise). + if sc and max(sc["delta"], sc["delta_prev"]) >= limit: + topics.append(tpl.topics["scale_rise"].format(name=tpl.metrics.get(metric, metric))) + if s["events"]["quit_intent"]: + topics.append(tpl.topics["quit"]) + if s["events"]["side_effects"]: + topics.append(tpl.topics["side_effects"]) + adh = s["adherence"] + ratio = adh["ratio"] if adh["ratio"] is not None else adh["ratio_7d"] + if ratio is not None and ratio < th.adherence_min: + topics.append(tpl.topics["adherence"]) + if s["sleep"]["nights_lt4"] >= th.sleep_nights: + topics.append(tpl.topics["sleep"]) + if s["alcohol"]["days"] >= th.alcohol_days: + topics.append(tpl.topics["alcohol"]) + if s["mood"]["last3"] is not None and s["mood"]["last3"] <= th.mood_low: + topics.append(tpl.topics["mood"]) + return topics[: th.max_topics] + + +def build_structured(card: PatientCard, *, period_days: int = 14, today: date | None = None) -> dict[str, Any]: + """Aggregate the card into numbers and flags for the last `period_days` days (inclusive of `today`). + + Days are expressed as therapy days (day 1 = first day of therapy; ≤ 0 = before it), derived from + `patient.therapy_day`, which corresponds to the reference day. Only structured fields are read: + alert `reason` and `patient_name`, `display_name`, `token` and the safety plan are deliberately ignored. + """ + tpl = load_template() + p = card.patient + ref = today or _reference_day(card) + s: dict[str, Any] = { + "subject": "пациент", + "modules": list(p.modules), + "drug": p.drug, + "therapy_day": p.therapy_day, + } + + def day_no(d: date) -> int: + return p.therapy_day - (ref - d).days if ref else p.therapy_day + + since = ref - timedelta(days=period_days - 1) if ref else date.max + until = ref or date.min + s["period"] = {"days": period_days, "from_day": day_no(since) if ref else None, "to_day": p.therapy_day} + + # Scale trajectories (PHQ-9, PCL-5, ...): see _scale_trajectory for the reference-point logic. + scales = {m: _scale_trajectory(card.series, m, since, until, day_no) for m in SCALE_METRICS} + s["scales"] = {m: v for m, v in scales.items() if v is not None} + # Cards without a series (list-level data only) still carry the latest totals; show them as-is. + latest = {"phq9": p.phq9_last, "pcl5": p.pcl5_last} + s["scales_latest"] = {m: v for m, v in latest.items() if v is not None and m not in s["scales"]} + + # Mood 0–10 from daily check-ins: period mean, mean of the last 3 recorded days (recent drift), minimum. + mood = _daily(card.series, "mood", since, until) + mood_vals = [mood[d] for d in sorted(mood)] + s["mood"] = { + "days": len(mood_vals), + "avg": round(sum(mood_vals) / len(mood_vals), 1) if mood_vals else None, + "last3": round(sum(mood_vals[-3:]) / len(mood_vals[-3:]), 1) if mood_vals else None, + "min": _num(min(mood_vals)) if mood_vals else None, + } + + # Sleep: nights shorter than 4 h (count and longest consecutive run, cf. rule sleep_lt4_3n). + sleep = _daily(card.series, "sleep_hours", since, until) + short = [d for d, h in sleep.items() if h < SLEEP_SHORT_HOURS] + s["sleep"] = { + "nights": len(sleep), + "avg_hours": round(sum(sleep.values()) / len(sleep), 1) if sleep else None, + "nights_lt4": len(short), + "max_streak_lt4": _max_streak(short), + } + + # Alcohol: daily bucket 0 = none, 1 = 1–2 drinks, 2 = more; any value > 0 is a drinking day. + alcohol = _daily(card.series, "alcohol", since, until) + drinking = [d for d, v in alcohol.items() if v > 0] + s["alcohol"] = { + "days_recorded": len(alcohol), + "days": len(drinking), + "heavy_days": sum(1 for v in alcohol.values() if v >= 2), + "max_streak": _max_streak(drinking), + } + + # Adherence: taken / planned over resolved doses in the window; `pending` (not yet due) is not counted. + doses = [d for d in card.doses if d.status != "pending" and since <= d.planned_at.date() <= until] + taken = sum(1 for d in doses if d.status == "taken") + s["adherence"] = { + "taken": taken, + "planned": len(doses), + "ratio": round(taken / len(doses), 2) if doses else None, + "missed_days": sorted({day_no(d.planned_at.date()) for d in doses if d.status in ("missed", "skipped")}), + # Precomputed 7-day share from the patient list, used only when the card has no dose log. + "ratio_7d": p.adherence_7d if not doses else None, + } + + # Events by alert code inside the window (acked or not): the rules engine already decided them (ADR-007). + window_alerts = [a for a in card.alerts if since <= a.ts.date() <= until] + s["events"] = { + "crisis": sorted({day_no(a.ts.date()) for a in window_alerts if a.level == "acute" or a.code in CRISIS_CODES}), + "quit_intent": sorted({day_no(a.ts.date()) for a in window_alerts if a.code in QUIT_CODES}), + "side_effects": sorted({day_no(a.ts.date()) for a in window_alerts if a.code.startswith(SIDE_EFFECT_PREFIX)}), + } + + # Open (not acked) alerts regardless of the window: an unacknowledged signal is relevant until handled. + open_alerts = [a for a in card.alerts if a.acked_at is None] + s["open_alerts"] = {lvl: n for lvl in LEVEL_ORDER if (n := sum(1 for a in open_alerts if a.level == lvl))} + if not card.alerts and p.open_alerts: + # List-level card without the alert feed: only the count and the top level are known. + s["open_alerts"] = {p.top_alert_level or "info": p.open_alerts} + s["open_alert_codes"] = sorted({a.code for a in open_alerts}) + s["acute_open"] = [ + {"code": a.code, "therapy_day": day_no(a.ts.date())} + for a in sorted(open_alerts, key=lambda a: a.ts) + if a.level == "acute" + ] + + risk = p.discontinuation_risk + s["discontinuation_risk"] = {"score": risk.score, "level": risk.level, "factors": list(risk.factors)} + s["discuss"] = _discuss_topics(s, tpl.thresholds, tpl) + return s + + +# --- deterministic rendering ---------------------------------------------------------------------------- + + +def _days(days: list[int]) -> str: + return ", ".join(str(d) for d in days) + + +def _signed(value: int | float) -> str: + return f"+{value}" if value > 0 else f"−{abs(value)}" if value < 0 else "±0" + + +def render_template(s: dict[str, Any]) -> str: + """3–5 lines from the structured dict: safety/alerts, scales, wellbeing, therapy, topics for the visit.""" + tpl = load_template() + L = tpl.lines + lines: list[str] = [] + + # Line 1 — safety first: an open acute alert always leads, then the rest of the open alerts. + if s.get("acute_open"): + codes = ", ".join(tpl.codes.get(a["code"], a["code"]) for a in s["acute_open"]) + lines.append(L["safety"].format(codes=codes, day=s["acute_open"][-1]["therapy_day"])) + elif s.get("open_alerts"): + alerts = ", ".join(f"{tpl.levels.get(lvl, lvl)} — {n}" for lvl, n in s["open_alerts"].items()) + lines.append(L["alerts"].format(alerts=alerts)) + elif s.get("drug") or s.get("scales") or s.get("scales_latest") or s.get("adherence", {}).get("planned"): + lines.append(L["no_alerts"]) + + scales = [ + L["scale"].format( + name=tpl.metrics.get(m, m), + values=" → ".join(str(v) for v in sc["values"]), + delta=_signed(sc["delta"]), + unscheduled=L["unscheduled"] if sc["last_unscheduled"] else "", + ) + for m, sc in s.get("scales", {}).items() + ] + scales += [ + L["scale_latest"].format(name=tpl.metrics.get(m, m), value=v) for m, v in s.get("scales_latest", {}).items() + ] + if scales: + lines.append(L["scales"].format(scales="; ".join(scales))) + + well: list[str] = [] + mood, sleep, alc = s.get("mood", {}), s.get("sleep", {}), s.get("alcohol", {}) + if mood.get("days"): + well.append(L["mood"].format(avg=mood["avg"], last3=mood["last3"], min=mood["min"])) + if sleep.get("nights_lt4"): + well.append(L["sleep"].format(nights=sleep["nights_lt4"], streak=sleep["max_streak_lt4"])) + if alc.get("days"): + well.append(L["alcohol"].format(days=alc["days"], streak=alc["max_streak"])) + if well: + lines.append(L["wellbeing"].format(parts="; ".join(well))) + + therapy: list[str] = [] + if s.get("drug"): + therapy.append(L["therapy_drug"].format(drug=s["drug"], day=s["therapy_day"])) + adh = s.get("adherence", {}) + if adh.get("planned"): + therapy.append(L["adherence"].format(taken=adh["taken"], planned=adh["planned"], pct=round(100 * adh["ratio"]))) + if adh["missed_days"]: + therapy.append(L["missed"].format(days=_days(adh["missed_days"]))) + elif adh.get("ratio_7d") is not None: + therapy.append(L["adherence_7d"].format(pct=round(100 * adh["ratio_7d"]))) + events = s.get("events", {}) + if events.get("side_effects"): + therapy.append(L["side_effects"].format(days=_days(events["side_effects"]))) + elif therapy: + therapy.append(L["no_side_effects"]) + if events.get("quit_intent"): + therapy.append(L["quit"].format(days=_days(events["quit_intent"]))) + risk = s.get("discontinuation_risk") + if risk and risk["level"] != "low": + factors = f": {', '.join(risk['factors'])}" if risk["factors"] else "" + level = tpl.risk_levels.get(risk["level"], risk["level"]) + therapy.append(L["risk"].format(level=level, score=risk["score"], factors=factors)) + if therapy: + lines.append(L["therapy"].format(parts="; ".join(therapy))) + + if s.get("discuss"): + lines.append(L["discuss"].format(topics="; ".join(s["discuss"]))) + return "\n".join(lines) if lines else tpl.empty + + +# --- LLM + cache ---------------------------------------------------------------------------------------- + +# The dashboard polls the card every 2–3 s; the key changes only when the underlying data changes. +_cache: OrderedDict[tuple[str, str, str], str] = OrderedDict() + + +def clear_cache() -> None: + _cache.clear() + + +def _fingerprint(structured: dict[str, Any]) -> str: + raw = json.dumps(structured, ensure_ascii=False, sort_keys=True, default=str) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def _finalize(llm_text: str, structured: dict[str, Any], template_text: str) -> str: + """Pick LLM text or our template; guarantee that an open acute alert is mentioned first.""" + text = (llm_text or "").strip() + # The gateway returns its generic label dump when the provider is the mock, failed or was filtered. + if not text or text == render_summary(structured).strip() or is_forbidden(text): + return template_text + if structured.get("acute_open") and "безопас" not in text.lower(): + text = f"{template_text.splitlines()[0]}\n{text}" + return text + + +async def build_summary(card: PatientCard, *, period_days: int = 14, today: date | None = None) -> str: + """3–5 line Russian summary for the doctor; cached per (token, provider, structured-data hash).""" + structured = build_structured(card, period_days=period_days, today=today) + gateway = get_gateway() + key = (card.patient.token, gateway.provider_name, _fingerprint(structured)) + if key in _cache: + _cache.move_to_end(key) + return _cache[key] + template_text = render_template(structured) + text = _finalize(await gateway.summarize_for_doctor(structured), structured, template_text) + _cache[key] = text + if len(_cache) > CACHE_SIZE: + _cache.popitem(last=False) + return text diff --git a/config/texts/summary.yaml b/config/texts/summary.yaml new file mode 100644 index 0000000..3ae99f3 --- /dev/null +++ b/config/texts/summary.yaml @@ -0,0 +1,89 @@ +# Детерминированное саммари динамики для врача (#19, SPEC §7 п.4). +# Строится ТОЛЬКО по структурированным данным карточки (баллы, флаги, приём доз) — без текста пациента. +# Используется: (1) как ответ mock-провайдера, (2) как запасной текст, если GigaChat недоступен или его ответ +# не прошёл фильтр запретов. Правила текстов: без диагнозов, без советов по дозам — решения принимает врач. +# Зона clinical вычитывает формулировки и пороги. + +empty: "Нет данных за период." + +# Пороги для блока «Что обсудить на визите». Совпадают с config/rules.yaml, где это применимо. +thresholds: + phq9_rise: 5 # рост PHQ-9 ≥ 5 баллов (rules.yaml: phq9_rise) + pcl5_rise: 10 # рост PCL-5 ≥ 10 баллов (rules.yaml: pcl5_rise) + gad7_rise: 5 + sleep_nights: 3 # ночей со сном < 4 ч за период + alcohol_days: 3 # дней с алкоголем за период + mood_low: 3 # среднее настроение за последние 3 дня ≤ порога (0–10) + adherence_min: 0.8 # доля принятых доз ниже порога + max_topics: 5 + +metrics: + phq9: "PHQ-9" + pcl5: "PCL-5" + gad7: "GAD-7" + auditc: "AUDIT-C" + dar5: "DAR-5" + +levels: + acute: "острый" + high: "высокий" + moderate: "умеренный" + info: "инфо" + +risk_levels: + low: "низкий" + medium: "средний" + high: "высокий" + +# Короткие подписи кодов алертов (id правил из config/rules.yaml). +codes: + phq9_item9: "PHQ-9 п.9" + cssrs_high: "C-SSRS высокий" + cssrs_moderate: "C-SSRS умеренный" + weapon_access_risk: "доступ к оружию" + phq9_severe: "PHQ-9 тяжёлая степень" + pcl5_severe: "PCL-5 выше порога" + phq9_rise: "рост PHQ-9" + pcl5_rise: "рост PCL-5" + auditc_positive: "AUDIT-C положительный" + quit_intent: "намерение прекратить приём" + missed_doses_7d: "пропуски приёма" + side_effect_3d: "побочные эффекты" + sleep_lt4_3n: "сон < 4 ч" + alcohol_3d: "алкоголь" + mood_low_3d: "сниженное настроение" + no_contact_3d: "нет связи" + young_adult_start: "возраст < 25" + +lines: + safety: "Безопасность: открыт острый сигнал ({codes}, день {day}) — требует внимания врача в первую очередь." + alerts: "Открытые алерты: {alerts}." + no_alerts: "Открытых алертов нет." + scales: "Шкалы: {scales}." + scale: "{name} {values} ({delta} к исходному{unscheduled})" + scale_latest: "{name} = {value} (последнее измерение)" + unscheduled: ", последний — внеплановый" + wellbeing: "Самочувствие: {parts}." + mood: "настроение в среднем {avg}/10, последние 3 дня {last3} (мин. {min})" + sleep: "сон < 4 ч — {nights} ноч. (подряд до {streak})" + alcohol: "алкоголь — {days} дн. (подряд до {streak})" + therapy: "Терапия: {parts}." + therapy_drug: "{drug}, день {day}" + adherence: "принято {taken}/{planned} ({pct}%)" + adherence_7d: "приверженность за 7 дней {pct}%" + missed: "пропуски — день {days}" + side_effects: "побочные эффекты — день {days}" + no_side_effects: "побочные эффекты не отмечены" + quit: "намерение прекратить — день {days}" + risk: "риск отказа от терапии {level} ({score}){factors}" + discuss: "Что обсудить на визите: {topics}." + +topics: + safety: "безопасность и план безопасности" + scale_rise: "рост {name}" + quit: "намерение прекратить приём" + side_effects: "переносимость (побочные эффекты)" + adherence: "пропуски приёма" + sleep: "сон" + alcohol: "алкоголь на фоне терапии" + mood: "сниженное настроение" diff --git a/tests/test_llm_summary.py b/tests/test_llm_summary.py new file mode 100644 index 0000000..ad9b0f3 --- /dev/null +++ b/tests/test_llm_summary.py @@ -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"]