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>
388 lines
17 KiB
Python
388 lines
17 KiB
Python
"""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
|