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],

View File

@@ -0,0 +1,106 @@
"""Couplage worker → DB VWB (mutualisé) + persistance « dossier patient extrait ».
Le worker/serveur streaming est un process distinct du backend VWB : il n'a
pas d'app Flask en mémoire. Ce module fournit :
- ``vwb_app_context()`` : un app-context Flask lazy (singleton module) lié au
fichier SQLite VWB ``visual_workflow_builder/backend/instance/workflows.db``,
avec ``db.init_app`` (db de ``db.models``). Réutilisable par tout module
serveur qui doit écrire dans la DB VWB (R1, extraction métier, …).
- ``persist_extracted_dossier(...)`` : depuis une grille OCR
(``List[List[cell]]``), crée ExtractionJob → ExtractedTable → ExtractedField
et commit. Suppose un app-context actif (comme le pont R1 existant).
⚠️ CANAL EXTRACTION = données patient EN CLAIR (volontaire) : aucune
tokenisation/assainissement PII ici (cf. note dans db/models.py).
"""
import sys
import uuid
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Dict, List, Optional
# Ajout du backend VWB au sys.path à l'import → rend ``db.models`` importable
# (couplage worker→DB VWB mutualisé ; identique au pattern stream_processor).
_VWB_BACKEND = Path(__file__).resolve().parents[2] / "visual_workflow_builder" / "backend"
if str(_VWB_BACKEND) not in sys.path:
sys.path.insert(0, str(_VWB_BACKEND))
# App Flask lazy (singleton module) — un seul db.init_app pour tout le process.
_vwb_app = None
@contextmanager
def vwb_app_context():
"""App-context Flask VWB (lazy singleton) sur instance/workflows.db.
À utiliser via ``with vwb_app_context(): ...`` autour des appels qui
nécessitent ``db.session`` (ex. persist_extracted_dossier).
"""
global _vwb_app
if _vwb_app is None:
from flask import Flask
from db.models import db
db_path = _VWB_BACKEND / "instance" / "workflows.db"
app = Flask("worker_vwb")
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_path}"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db.init_app(app)
_vwb_app = app
with _vwb_app.app_context():
yield
def persist_extracted_dossier(
grid: List[List[Dict[str, Any]]],
*,
patient_ref: Optional[str],
source_session_id: Optional[str],
screenshot_ref: Optional[str],
screen_bbox: Optional[Dict[str, Any]],
status: str,
) -> str:
"""Persiste un « dossier patient extrait » et retourne le job_id.
Crée 1 ExtractionJob → 1 ExtractedTable → N ExtractedField (une par
cellule de la grille), puis commit. Suppose un app-context VWB actif
(fourni par ``vwb_app_context()`` ou par l'appelant, comme le pont R1).
⚠️ ``patient_ref`` et ``cell["text"]`` sont stockés EN CLAIR (volontaire) :
le but est de constituer le dossier, pas d'anonymiser.
"""
from db.models import db, ExtractionJob, ExtractedTable, ExtractedField
job = ExtractionJob(
id=uuid.uuid4().hex,
patient_ref=patient_ref,
source_session_id=source_session_id,
status=status,
)
db.session.add(job)
table = ExtractedTable(
id=uuid.uuid4().hex,
job_id=job.id,
screen_bbox=screen_bbox,
screenshot_ref=screenshot_ref,
)
db.session.add(table)
for row in grid or []:
for cell in row or []:
db.session.add(ExtractedField(
id=uuid.uuid4().hex,
table_id=table.id,
row=cell.get("row"),
col=cell.get("col"),
value=cell.get("text"),
bbox=cell.get("bbox"),
confidence=cell.get("confidence"),
))
db.session.commit()
return job.id