Files
rpa_vision_v3/core/extraction/role_mapper.py
Dom 14b1bf844a feat(extraction): role_mapper — reconstruction de champs ancrée OCR (0 hallucination)
Le VLM ne fournit que des value_ids ; la value est reconstruite côté Python
depuis l'OCR (le texte VLM est ignoré) -> 0 hallucination par construction.
9 tests unitaires : ancrage, ids hors plage, dédup ordonnée, value_ids vide,
confidence min, bbox englobante, anti-injection. Module pur, non branché runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 10:38:11 +02:00

124 lines
4.1 KiB
Python

"""role_mapper — reconstruction de champs ANCRÉS sur l'OCR.
Principe cardinal (gate validé le 30/06 sur DPI urgences réel) :
le VLM ne fournit QUE des ids de tokens OCR (`value_ids`) ; la valeur est
reconstruite ici depuis l'OCR. Aucun texte produit par le VLM ne peut entrer
dans une valeur → **0 hallucination par construction**.
Ce module est volontairement PUR (pas d'appel réseau/VLM) : il prend les tokens
OCR (issus de `core.llm.ocr_extractor.extract_grid_from_image`) et la réponse
déjà désérialisée du VLM, et produit des champs ancrés. L'appel VLM lui-même
est orchestré ailleurs (et mockable), pour rester testable hors-ligne.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import List, Optional, Sequence, Tuple
BBox = Tuple[int, int, int, int] # (x_min, y_min, x_max, y_max)
@dataclass
class OcrToken:
"""Un token OCR indexé par un id stable."""
id: int
text: str
confidence: float = 1.0
bbox: Optional[BBox] = None
@dataclass
class MappedField:
"""Un champ {rôle → valeur} dont la valeur est 100% issue de l'OCR."""
label: str
value: str
value_ids: List[int]
confidence: float
bbox: Optional[BBox]
anchored: bool
invalid_ids: List[int]
def _norm_bbox(bbox) -> Optional[BBox]:
"""Normalise une bbox en (x_min, y_min, x_max, y_max).
Accepte soit 4 points EasyOCR `[[x,y], ...]`, soit un quadruplet déjà plat.
"""
if bbox is None:
return None
if len(bbox) == 4 and all(isinstance(v, (int, float)) for v in bbox):
return (int(bbox[0]), int(bbox[1]), int(bbox[2]), int(bbox[3]))
xs = [p[0] for p in bbox]
ys = [p[1] for p in bbox]
return (int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys)))
def tokens_from_grid(grid: Sequence[Sequence[dict]]) -> List[OcrToken]:
"""Convertit une grille `extract_grid_from_image` en tokens indexés (id séquentiel).
L'ordre des ids suit l'ordre de lecture de la grille (lignes top→bottom,
colonnes left→right), ce qui donne au VLM un référentiel stable.
"""
tokens: List[OcrToken] = []
tid = 0
for row in grid:
for cell in row:
tokens.append(OcrToken(
id=tid,
text=cell["text"],
confidence=float(cell.get("confidence", 1.0)),
bbox=_norm_bbox(cell.get("bbox")),
))
tid += 1
return tokens
def _enclosing_bbox(bboxes: Sequence[Optional[BBox]]) -> Optional[BBox]:
present = [b for b in bboxes if b is not None]
if not present:
return None
return (
min(b[0] for b in present),
min(b[1] for b in present),
max(b[2] for b in present),
max(b[3] for b in present),
)
def reconstruct_fields(
tokens: Sequence[OcrToken],
vlm_fields: Sequence[dict],
) -> List[MappedField]:
"""Reconstruit les champs à partir des tokens OCR et des `value_ids` du VLM.
Pour chaque champ VLM `{label, value_ids:[...]}` :
- déduplique les ids en préservant l'ordre de lecture donné par le VLM ;
- filtre les ids hors OCR (listés dans `invalid_ids`) ;
- reconstruit la valeur par concaténation des `text` des tokens valides ;
- confidence = min des tokens ancrés (le plus prudent), bbox = englobante.
Tout champ `value`/texte fourni par le VLM est IGNORÉ : seule la liste
d'ids fait foi (anti-hallucination).
"""
by_id = {t.id: t for t in tokens}
out: List[MappedField] = []
for vf in vlm_fields:
label = vf.get("label", "")
seen: List[int] = []
for i in (vf.get("value_ids") or []):
if i not in seen:
seen.append(i)
valid = [i for i in seen if i in by_id]
invalid = [i for i in seen if i not in by_id]
toks = [by_id[i] for i in valid]
out.append(MappedField(
label=label,
value=" ".join(t.text for t in toks),
value_ids=valid,
confidence=min((t.confidence for t in toks), default=0.0),
bbox=_enclosing_bbox([t.bbox for t in toks]),
anchored=bool(valid),
invalid_ids=invalid,
))
return out