release: dev → main (сдача хакатона) #106

Merged
Eleboba merged 128 commits from dev into main 2026-10-04 23:00:56 +03:00
3 changed files with 650 additions and 0 deletions
Showing only changes of commit 49d5f8a4e5 - Show all commits
+387
View File
@@ -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
+89
View File
@@ -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: "сниженное настроение"
+174
View File
@@ -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"]