Files
PTSRaiAgent/app/llm/summary.py
T
ElebobaandClaude Opus 5.5 49d5f8a4e5
CI / ruff · pytest (pull_request) Successful in 24s
feat(bot): add doctor summary built from structured card data
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>
2026-10-03 15:33:37 +03:00

388 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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