DocUA commited on
Commit
04fe8ef
·
1 Parent(s): ea04df8

feat: escalation threshold derived from costs; /metrics exposes decision economics

Browse files
livemedcard/api.py CHANGED
@@ -36,6 +36,7 @@ from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response
36
  from fastapi.responses import FileResponse
37
  from pydantic import BaseModel
38
 
 
39
  from .bundle import from_bundle, to_bundle
40
  from .config import (
41
  EXTRACTOR,
@@ -316,6 +317,24 @@ def metrics(state: _State = Depends(_get_state)):
316
  log = card.audit_log()
317
  n = len(log)
318
  escalated = sum(1 for a in log if a["escalate"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  return {
320
  "documents": card.document_count,
321
  "observations": len(card.observations),
@@ -325,6 +344,12 @@ def metrics(state: _State = Depends(_get_state)):
325
  "auto": n - escalated,
326
  "escalation_rate": round(escalated / n, 3) if n else 0.0,
327
  "active_signals": len(card.current_signals()),
 
 
 
 
 
 
328
  }
329
 
330
 
 
36
  from fastapi.responses import FileResponse
37
  from pydantic import BaseModel
38
 
39
+ from . import economics
40
  from .bundle import from_bundle, to_bundle
41
  from .config import (
42
  EXTRACTOR,
 
317
  log = card.audit_log()
318
  n = len(log)
319
  escalated = sum(1 for a in log if a["escalate"])
320
+
321
+ # Економіка рішень: у що обходиться поточна політика L3 і що саме купує
322
+ # ескалація. `no_escalation` — контрфактичний прогін тієї самої партії
323
+ # документів без жодної передачі людині: різниця і є цінністю шару L3.
324
+ cost_actual = sum(
325
+ economics.expected_cost(
326
+ a["extraction_confidence"], a.get("hazard", "medication"),
327
+ escalated=a["escalate"], high_risk_signal=a.get("high_risk", False),
328
+ )
329
+ for a in log
330
+ )
331
+ cost_no_escalation = sum(
332
+ economics.expected_cost(
333
+ a["extraction_confidence"], a.get("hazard", "medication"),
334
+ escalated=False, high_risk_signal=a.get("high_risk", False),
335
+ )
336
+ for a in log
337
+ )
338
  return {
339
  "documents": card.document_count,
340
  "observations": len(card.observations),
 
344
  "auto": n - escalated,
345
  "escalation_rate": round(escalated / n, 3) if n else 0.0,
346
  "active_signals": len(card.current_signals()),
347
+ "economics": {
348
+ **economics.summary(),
349
+ "expected_cost": round(cost_actual, 2),
350
+ "expected_cost_without_escalation": round(cost_no_escalation, 2),
351
+ "escalation_saves": round(cost_no_escalation - cost_actual, 2),
352
+ },
353
  }
354
 
355
 
livemedcard/config.py CHANGED
@@ -11,8 +11,24 @@ from pathlib import Path
11
  DATA_DIR = Path(__file__).parent / "data"
12
  STATIC_DIR = Path(__file__).parent / "static"
13
 
14
- # Поріг впевненості витягу L2b, нижче якого документ іде на перегляд людиною (L3).
15
- LOW_CONFIDENCE = float(os.getenv("LMC_LOW_CONFIDENCE", "0.6"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  # Впевненість, яку StubExtractor приписує кожному словниковому збігу.
18
  # Реальний екстрактор (MedGemma) має ставити модельну впевненість замість цієї.
 
11
  DATA_DIR = Path(__file__).parent / "data"
12
  STATIC_DIR = Path(__file__).parent / "static"
13
 
14
+ # ── Економіка рішень (виводить поріг ескалації; див. economics.py) ────────────
15
+ # Поріг L3 більше не константа: він рахується як c* = 1 - C_human/(π·C_harm).
16
+ # Числа нижче — робочі ПРИПУЩЕННЯ, не дані; перед клінічним використанням
17
+ # замінити на citable (AHRQ HCUP для вартості ADE-госпіталізації тощо).
18
+ COST_HUMAN_REVIEW = float(os.getenv("LMC_COST_HUMAN_REVIEW", "5")) # перевірка людиною
19
+ COST_HARM_MEDICATION = float(os.getenv("LMC_COST_HARM_MEDICATION", "15000")) # ADE-госпіталізація
20
+ COST_HARM_MEASUREMENT = float(os.getenv("LMC_COST_HARM_MEASUREMENT", "500")) # пропущений тренд
21
+ HAZARD_PRIOR_MEDICATION = float(os.getenv("LMC_HAZARD_PRIOR_MEDICATION", "0.01"))
22
+ HAZARD_PRIOR_MEASUREMENT = float(os.getenv("LMC_HAZARD_PRIOR_MEASUREMENT", "0.05"))
23
+ # Ймовірність, що знайдена (детерміновано!) небезпека таки нашкодить, якщо документ
24
+ # не проглянула людина: родина може не зрозуміти сигналу або не дійти до лікаря.
25
+ P_UNADDRESSED = float(os.getenv("LMC_P_UNADDRESSED", "0.2"))
26
+
27
+ # Явний адміністративний override порога (env `LMC_LOW_CONFIDENCE`). Порожньо →
28
+ # поріг виводиться з вартостей. Лишається для тестів і для випадку, коли оператор
29
+ # свідомо задає поріг вручну.
30
+ _low_conf_env = os.getenv("LMC_LOW_CONFIDENCE", "")
31
+ LOW_CONFIDENCE_OVERRIDE: float | None = float(_low_conf_env) if _low_conf_env else None
32
 
33
  # Впевненість, яку StubExtractor приписує кожному словниковому збігу.
34
  # Реальний екстрактор (MedGemma) має ставити модельну впевненість замість цієї.
livemedcard/dto.py CHANGED
@@ -92,3 +92,10 @@ class AuditEntry(BaseModel):
92
  n_signals: int
93
  route_to: Literal["auto", "L4_human"]
94
  escalate: bool
 
 
 
 
 
 
 
 
92
  n_signals: int
93
  route_to: Literal["auto", "L4_human"]
94
  escalate: bool
95
+ # Чим ризикував документ — від цього залежав поріг ескалації (economics).
96
+ # Дефолт зберігає сумісність зі старими знімками.
97
+ hazard: str = "medication"
98
+ # Чи на момент рішення був детермінований сигнал major/critical. Це ДРУГЕ,
99
+ # незалежне джерело вартості: небезпека вже знайдена, і питання лише в тому,
100
+ # чи її хтось прогляне.
101
+ high_risk: bool = False
livemedcard/economics.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Економіка рішень TRACE: поріг ескалації як відношення вартостей.
2
+
3
+ Мотивація. `LOW_CONFIDENCE = 0.6` було магічним числом: його ніхто не виводив, і
4
+ захистити його перед регулятором (EU AI Act ст.14 — обґрунтований human oversight)
5
+ нічим. Тут воно виводиться.
6
+
7
+ **Ключова властивість TRACE.** Помилка безпеки факторизується:
8
+
9
+ P(пропуск) = (1 - R) · 1 + R · P(правило не спрацювало)
10
+ └─────────── = 0 ────────┘
11
+
12
+ Другий доданок строго нульовий: правила L1/L2a вичерпні над своєю БД і нічого не
13
+ «вирішують». Отже весь ризик безпеки зведено до одного вимірюваного числа —
14
+ recall витягу R. Саме тому впевненість витягу (а не якась «впевненість моделі у
15
+ висновку») — єдина величина, за якою має ухвалюватись рішення про ескалацію.
16
+
17
+ **Правило рішення.** Ескалювати до людини варто тоді, коли очікувана шкода від
18
+ автоматичної обробки перевищує вартість перевірки:
19
+
20
+ (1 - c) · π · C_harm > C_human
21
+ ⟹ c* = 1 - C_human / (π · C_harm)
22
+
23
+ де c — впевненість витягу, π — частка документів цього класу, у яких узагалі є
24
+ факт, чий пропуск шкодить, C_harm — вартість такої шкоди, C_human — вартість
25
+ перегляду людиною.
26
+
27
+ Наслідок, який варто проговорити вголос: при асиметрії «$5 проти $15 000» поріг
28
+ для документів із ліками виходить ≈0.97, тобто **майже будь-який сумнів має йти до
29
+ людини**. Для документів лише з показниками (пропуск тренду не є гострою подією)
30
+ поріг помітно нижчий, і автоматична обробка економічно виправдана. Тобто поріг —
31
+ не один на всіх, а функція від того, чим документ ризикує.
32
+
33
+ ⚠ Числа вартостей нижче — **робочі припущення, не дані**. Перед будь-яким
34
+ клінічним чи інвесторським використанням їх треба замінити на citable: вартість
35
+ ADE-госпіталізації (AHRQ HCUP), базовий ризик для 65+ на 5+ ліках, реальну
36
+ вартість хвилини перевірки. Модель від цього не змінюється — змінюються входи.
37
+ """
38
+ from __future__ import annotations
39
+
40
+ from typing import Literal
41
+
42
+ from .config import (
43
+ COST_HARM_MEDICATION,
44
+ COST_HARM_MEASUREMENT,
45
+ COST_HUMAN_REVIEW,
46
+ HAZARD_PRIOR_MEDICATION,
47
+ HAZARD_PRIOR_MEASUREMENT,
48
+ LOW_CONFIDENCE_OVERRIDE,
49
+ P_UNADDRESSED,
50
+ )
51
+
52
+ # Чим ризикує документ. `medication` — з нього витягнуто препарати, тож пропуск
53
+ # факту може коштувати взаємодії або передозування (гостра подія). `measurement` —
54
+ # лише показники: пропуск тренду шкодить, але не гостро. `none` — фактів немає.
55
+ Hazard = Literal["medication", "measurement", "none"]
56
+
57
+ _PARAMS: dict[str, tuple[float, float]] = { # hazard → (π, C_harm)
58
+ "medication": (HAZARD_PRIOR_MEDICATION, COST_HARM_MEDICATION),
59
+ "measurement": (HAZARD_PRIOR_MEASUREMENT, COST_HARM_MEASUREMENT),
60
+ "none": (HAZARD_PRIOR_MEASUREMENT, COST_HARM_MEASUREMENT),
61
+ }
62
+
63
+ # Поріг не має вироджуватись у крайнощі: 1.0 означав би «ніколи не довіряти», 0.0 —
64
+ # «ніколи не перевіряти». Обидва роблять шар L3 беззмістовним.
65
+ _FLOOR, _CEIL = 0.05, 0.99
66
+
67
+
68
+ def escalation_threshold(hazard: Hazard = "medication") -> float:
69
+ """Поріг впевненості витягу, нижче якого документ іде до людини.
70
+
71
+ Виводиться з вартостей: ``c* = 1 - C_human / (π · C_harm)``. Env
72
+ ``LMC_LOW_CONFIDENCE`` лишається явним override — для тестів і для випадку,
73
+ коли оператор свідомо задає поріг адміністративно, а не економічно.
74
+ """
75
+ if LOW_CONFIDENCE_OVERRIDE is not None:
76
+ return LOW_CONFIDENCE_OVERRIDE
77
+ prior, harm = _PARAMS[hazard]
78
+ expected_harm = prior * harm
79
+ if expected_harm <= 0:
80
+ return _FLOOR
81
+ return min(_CEIL, max(_FLOOR, 1.0 - COST_HUMAN_REVIEW / expected_harm))
82
+
83
+
84
+ def expected_cost(
85
+ confidence: float,
86
+ hazard: Hazard = "medication",
87
+ *,
88
+ escalated: bool = False,
89
+ high_risk_signal: bool = False,
90
+ ) -> float:
91
+ """Очікувана вартість одного рішення (умовні одиниці вартості).
92
+
93
+ Ескалація коштує рівно ``C_human``: людина перевіряє документ, і залишковий
94
+ ризик вважаємо нульовим (припущення на користь людини — воно ж робить оцінку
95
+ консервативною щодо цінності автоматизації).
96
+
97
+ В автоматичної обробки **два різні джерела шкоди**, і плутати їх не можна:
98
+
99
+ 1. **Ризик витягу** — факт міг не витягнутись: ``(1-c)·π·C_harm``. Тут π —
100
+ апріорна частка документів, у яких взагалі є що пропускати.
101
+ 2. **Непроглянута підтверджена небезпека** — детермінований сигнал уже
102
+ спрацював (взаємодія, перевищення дози), тобто небезпека не гіпотетична, а
103
+ наявна. Тоді π не потрібне: ризик у тому, що родина не відреагує на знайдене
104
+ без лікаря — ``P_unaddressed · C_harm``.
105
+
106
+ Без другого доданка модель вважала ескалацію на реальній взаємодії марною
107
+ витратою ($5 проти нуля) — і виходило, що шар L3 приносить збиток. Це був
108
+ дефект моделі, а не політики.
109
+ """
110
+ if escalated:
111
+ return COST_HUMAN_REVIEW
112
+ prior, harm = _PARAMS[hazard]
113
+ cost = max(0.0, 1.0 - confidence) * prior * harm
114
+ if high_risk_signal:
115
+ cost += P_UNADDRESSED * _PARAMS["medication"][1]
116
+ return cost
117
+
118
+
119
+ def hazard_of(*, has_medications: bool, has_observations: bool) -> Hazard:
120
+ """Чим ризикує документ — за тим, що з нього фактично витягнуто."""
121
+ if has_medications:
122
+ return "medication"
123
+ return "measurement" if has_observations else "none"
124
+
125
+
126
+ def summary() -> dict:
127
+ """Параметри та виведені пороги — для `/metrics` і для захисту рішення."""
128
+ return {
129
+ "cost_human_review": COST_HUMAN_REVIEW,
130
+ "cost_harm_medication": COST_HARM_MEDICATION,
131
+ "cost_harm_measurement": COST_HARM_MEASUREMENT,
132
+ "hazard_prior_medication": HAZARD_PRIOR_MEDICATION,
133
+ "hazard_prior_measurement": HAZARD_PRIOR_MEASUREMENT,
134
+ "p_unaddressed": P_UNADDRESSED,
135
+ "threshold_medication": round(escalation_threshold("medication"), 3),
136
+ "threshold_measurement": round(escalation_threshold("measurement"), 3),
137
+ "threshold_source": "override" if LOW_CONFIDENCE_OVERRIDE is not None else "derived",
138
+ }
livemedcard/layers/l3_router.py CHANGED
@@ -13,10 +13,10 @@ from __future__ import annotations
13
 
14
  from typing import Optional
15
 
16
- from ..config import LOW_CONFIDENCE
17
  from ..dto import Decision, Severity, Signal, severity_rank
 
18
 
19
- __all__ = ["route", "LOW_CONFIDENCE"]
20
 
21
 
22
  def route(
@@ -24,15 +24,22 @@ def route(
24
  extraction_confidence: float,
25
  doc_kind: str,
26
  lang: str = "uk",
 
27
  ) -> Decision:
28
- """Приймає рішення про маршрут документа: "auto" чи "L4_human"."""
 
 
 
 
 
29
  top: Optional[Signal] = max(
30
  signals, key=lambda s: severity_rank(s.severity), default=None
31
  )
32
  high_risk = top is not None and severity_rank(top.severity) >= severity_rank(
33
  _MAJOR
34
  )
35
- low_confidence = extraction_confidence < LOW_CONFIDENCE
 
36
  escalate = high_risk or low_confidence
37
 
38
  en = lang == "en"
@@ -47,10 +54,12 @@ def route(
47
  )
48
  if low_confidence:
49
  reasons.append(
50
- f"Low extraction confidence ({extraction_confidence:.2f}) — "
51
- f"human review required"
 
52
  if en else
53
- f"Низька впевненість витягу ({extraction_confidence:.2f}) — "
 
54
  f"потрібна перевірка людиною"
55
  )
56
  if not escalate:
 
13
 
14
  from typing import Optional
15
 
 
16
  from ..dto import Decision, Severity, Signal, severity_rank
17
+ from ..economics import Hazard, escalation_threshold
18
 
19
+ __all__ = ["route"]
20
 
21
 
22
  def route(
 
24
  extraction_confidence: float,
25
  doc_kind: str,
26
  lang: str = "uk",
27
+ hazard: Hazard = "medication",
28
  ) -> Decision:
29
+ """Приймає рішення про маршрут документа: "auto" чи "L4_human".
30
+
31
+ Поріг впевненості не константа: він виводиться з вартостей (`economics`) і
32
+ залежить від того, чим ризикує документ. Пропуск препарату коштує дорожче за
33
+ пропущений тренд, тож і планка довіри для нього вища.
34
+ """
35
  top: Optional[Signal] = max(
36
  signals, key=lambda s: severity_rank(s.severity), default=None
37
  )
38
  high_risk = top is not None and severity_rank(top.severity) >= severity_rank(
39
  _MAJOR
40
  )
41
+ threshold = escalation_threshold(hazard)
42
+ low_confidence = extraction_confidence < threshold
43
  escalate = high_risk or low_confidence
44
 
45
  en = lang == "en"
 
54
  )
55
  if low_confidence:
56
  reasons.append(
57
+ f"Extraction confidence {extraction_confidence:.2f} < {threshold:.2f} — "
58
+ f"the threshold follows from the cost of a missed fact, not a tuned "
59
+ f"hyperparameter — human review required"
60
  if en else
61
+ f"Впевненість витягу {extraction_confidence:.2f} < {threshold:.2f} — "
62
+ f"поріг виведено з вартості пропущеного факту, а не підібрано — "
63
  f"потрібна перевірка людиною"
64
  )
65
  if not escalate:
livemedcard/pipeline.py CHANGED
@@ -12,7 +12,15 @@ from __future__ import annotations
12
  from datetime import datetime, timezone
13
  from typing import Optional
14
 
15
- from .dto import AuditEntry, Document, ExtractionResult, Signal, TraceReport
 
 
 
 
 
 
 
 
16
  from .fhir import (
17
  Condition,
18
  DocumentReference,
@@ -120,8 +128,13 @@ class LiveMedCard:
120
  _assert_provenance(ext)
121
  self._absorb(ext)
122
  signals = self.current_signals()
123
- decision = l3_router.route(signals, ext.mean_confidence, doc.kind)
124
- self._record_audit(doc, ext, signals, decision)
 
 
 
 
 
125
  return TraceReport(
126
  document_id=doc.id, extraction=ext, signals=signals, decision=decision
127
  )
@@ -130,7 +143,10 @@ class LiveMedCard:
130
  return [self.ingest(d) for d in docs]
131
 
132
  # ── аудит рішень L3 ───────────────────────────────────────────────────────
133
- def _record_audit(self, doc, ext, signals, decision) -> None:
 
 
 
134
  self._audit.append(AuditEntry(
135
  ts=datetime.now(timezone.utc),
136
  document_id=doc.id,
@@ -139,6 +155,8 @@ class LiveMedCard:
139
  n_signals=len(signals),
140
  route_to=decision.route_to,
141
  escalate=decision.escalate,
 
 
142
  ))
143
 
144
  def audit_log(self) -> list[dict]:
 
12
  from datetime import datetime, timezone
13
  from typing import Optional
14
 
15
+ from . import economics
16
+ from .dto import (
17
+ AuditEntry,
18
+ Document,
19
+ ExtractionResult,
20
+ Signal,
21
+ TraceReport,
22
+ severity_rank,
23
+ )
24
  from .fhir import (
25
  Condition,
26
  DocumentReference,
 
128
  _assert_provenance(ext)
129
  self._absorb(ext)
130
  signals = self.current_signals()
131
+ # Чим ризикує саме цей документ — визначає планку довіри (economics).
132
+ hazard = economics.hazard_of(
133
+ has_medications=bool(ext.medications),
134
+ has_observations=bool(ext.observations),
135
+ )
136
+ decision = l3_router.route(signals, ext.mean_confidence, doc.kind, hazard=hazard)
137
+ self._record_audit(doc, ext, signals, decision, hazard)
138
  return TraceReport(
139
  document_id=doc.id, extraction=ext, signals=signals, decision=decision
140
  )
 
143
  return [self.ingest(d) for d in docs]
144
 
145
  # ── аудит рішень L3 ───────────────────────────────────────────────────────
146
+ def _record_audit(self, doc, ext, signals, decision, hazard: str = "medication") -> None:
147
+ high_risk = any(
148
+ severity_rank(s.severity) >= severity_rank("major") for s in signals
149
+ )
150
  self._audit.append(AuditEntry(
151
  ts=datetime.now(timezone.utc),
152
  document_id=doc.id,
 
155
  n_signals=len(signals),
156
  route_to=decision.route_to,
157
  escalate=decision.escalate,
158
+ hazard=hazard,
159
+ high_risk=high_risk,
160
  ))
161
 
162
  def audit_log(self) -> list[dict]: