"""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