Spaces:
Sleeping
Sleeping
DocUA commited on
Commit ·
04fe8ef
1
Parent(s): ea04df8
feat: escalation threshold derived from costs; /metrics exposes decision economics
Browse files- livemedcard/api.py +25 -0
- livemedcard/config.py +18 -2
- livemedcard/dto.py +7 -0
- livemedcard/economics.py +138 -0
- livemedcard/layers/l3_router.py +16 -7
- livemedcard/pipeline.py +22 -4
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 |
-
#
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 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 |
-
|
|
|
|
| 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"
|
| 51 |
-
f"
|
|
|
|
| 52 |
if en else
|
| 53 |
-
f"
|
|
|
|
| 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 .
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]:
|