feat(bot): LLM-саммари динамики для врача #49
@@ -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
|
||||
@@ -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: "сниженное настроение"
|
||||
@@ -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