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:
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user