feat(extraction): handler extract_dossier + pont worker→DB VWB mutualisé (brique 3)

vwb_db.py : couplage worker→DB VWB lazy (app Flask sur instance/workflows.db)
mutualisé (R1 + extraction), + persist_extracted_dossier (grille → Job/Table/Field).
replay_engine.py : handler _handle_extract_dossier_action — lit le screenshot,
extrait une grille structurée, gate qualité conservatrice (complete|needs_review),
persiste avec preuve (screenshot_ref/bbox/confidence). N'échoue JAMAIS le replay.
Données patient EN CLAIR (canal extraction, non anonymisé).

Réserve : dispatch runtime (api_stream.py) non encore branché — étape suivante,
à coordonner. Brique 3/4 de la verticale extraction dossier patient.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-06-29 14:18:08 +02:00
parent 9883cad012
commit 13f760a3b9
3 changed files with 467 additions and 0 deletions

View File

@@ -40,6 +40,7 @@ _ALLOWED_ACTION_TYPES = {
"pause_for_human", # Pause supervisée explicite (interceptée par /replay/next)
"extract_text", # OCR serveur sur dernier heartbeat → variable workflow
"extract_table", # OCR serveur + filtre regex → liste structurée (boucle)
"extract_dossier", # OCR grille structurée → dossier patient persisté (brique 3)
"extract_text_scroll", # Marker côté graphe — expansé en sous-actions par _edge_to_normalized_actions
"_concat_text_vars", # Action serveur interne (générée par expansion extract_text_scroll)
"t2a_decision", # Analyse LLM facturation T2A → variable workflow
@@ -53,6 +54,7 @@ _ALLOWED_ACTION_TYPES = {
_SERVER_SIDE_ACTION_TYPES = {
"extract_text",
"extract_table",
"extract_dossier",
"t2a_decision",
"llm_generate",
"_concat_text_vars",
@@ -2216,6 +2218,146 @@ def _handle_extract_table_action(
return bool(rows)
def _resolve_screenshot_path(replay_state: Dict[str, Any]) -> Optional[str]:
"""Résout le chemin du dernier screenshot (path disque ou base64 → temp).
Calque la source utilisée par extract_text/extract_table : priorité au
``last_screenshot`` (path ou data-URI base64). Retourne None si absent.
"""
raw_screenshot = replay_state.get("last_screenshot") or ""
if not raw_screenshot:
return None
if raw_screenshot.startswith("data:"):
try:
import base64 as _b64, tempfile
header, b64data = raw_screenshot.split(",", 1)
suffix = ".jpg" if "jpeg" in header else ".png"
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
tmp.write(_b64.b64decode(b64data))
tmp.close()
return tmp.name
except Exception as e:
logger.warning("extract_dossier: décodage base64 screenshot échoué: %s", e)
return None
if os.path.isfile(raw_screenshot):
return raw_screenshot
return None
def _gate_dossier_quality(
grid: List[List[Dict[str, Any]]],
*,
min_confidence: float,
expected_cols: Optional[int],
) -> str:
"""Gate qualité simple → 'complete' ou 'needs_review'.
'complete' SSI : grille non vide ET confiance médiane ≥ seuil ET (si
expected_cols fourni) au moins une ligne avec ce nombre de colonnes.
Sinon 'needs_review'. Volontairement conservatrice (default-review).
"""
confs = [
cell.get("confidence")
for row in grid for cell in row
if isinstance(cell.get("confidence"), (int, float))
]
if not confs:
return "needs_review"
confs.sort()
median = confs[len(confs) // 2]
if median < min_confidence:
return "needs_review"
if expected_cols is not None:
if not any(len(row) == expected_cols for row in grid):
return "needs_review"
return "complete"
def _handle_extract_dossier_action(
action: Dict[str, Any],
replay_state: Dict[str, Any],
session_id: str,
) -> bool:
"""Traite une action extract_dossier côté serveur (brique 3).
Lit le dernier screenshot, extrait une grille structurée via
``extract_grid_from_image``, applique une gate qualité, puis PERSISTE un
« dossier patient extrait » (Job/Table/Field) dans la DB VWB avec preuve
(screenshot_ref + screen_bbox + confidences). Le job_id est stocké dans
``replay_state["variables"][output_var]``.
Paramètres reconnus (action.parameters) :
output_var : nom de variable runtime (default "extracted_dossier")
patient_ref : référence patient EN CLAIR (volontaire) — non tokenisée
region : (x, y, w, h) px pour cropper avant OCR (None = plein)
min_confidence : seuil de confiance médiane pour 'complete' (default 0.6)
expected_cols : nb de colonnes attendu (optionnel) pour la gate
N'ÉCHOUE JAMAIS le replay : toute erreur → log + needs_review.
Retourne True SSI le dossier est persisté avec statut 'complete'.
"""
params = action.get("parameters") or {}
output_var = (params.get("output_var") or params.get("variable_name") or "extracted_dossier").strip()
patient_ref = params.get("patient_ref")
region = params.get("region") or None
try:
min_confidence = float(params.get("min_confidence", 0.6))
except (TypeError, ValueError):
min_confidence = 0.6
expected_cols = params.get("expected_cols")
if isinstance(expected_cols, str):
try:
expected_cols = int(expected_cols)
except ValueError:
expected_cols = None
job_id = ""
status = "needs_review"
try:
path = _resolve_screenshot_path(replay_state)
grid: List[List[Dict[str, Any]]] = []
if path:
from core.llm import extract_grid_from_image
grid = extract_grid_from_image(
path, region=tuple(region) if region else None
)
else:
logger.warning(
"extract_dossier : pas de screenshot pour session %s — needs_review",
session_id,
)
status = _gate_dossier_quality(
grid, min_confidence=min_confidence, expected_cols=expected_cols
)
from . import vwb_db
with vwb_db.vwb_app_context():
job_id = vwb_db.persist_extracted_dossier(
grid,
patient_ref=patient_ref,
source_session_id=session_id,
screenshot_ref=path,
screen_bbox=({"x": region[0], "y": region[1], "width": region[2], "height": region[3]}
if region and len(region) == 4 else None),
status=status,
)
except Exception as e:
# Ne JAMAIS échouer le replay : on log, on marque needs_review.
logger.warning(
"extract_dossier : échec persistance (%s) — needs_review, replay %s",
e, replay_state.get("replay_id", "?"),
)
status = "needs_review"
replay_state.setdefault("variables", {})[output_var] = job_id
logger.info(
"extract_dossier → variable '%s' job=%s statut=%s replay %s",
output_var, job_id or "?", status, replay_state.get("replay_id", "?"),
)
return status == "complete"
def _handle_t2a_decision_action(
action: Dict[str, Any],
replay_state: Dict[str, Any],