feat: T2A-Extractor pipeline with CIM-10 normalizer (31→0 warnings)

Initial commit with full extraction pipeline: PDF OCR (docTR), text
segmentation, LLM extraction (Ollama), deterministic post-processing
normalizer, validation, and Excel/CSV export.

The normalizer fixes OCR/LLM errors on CIM-10 codes:
- OCR digit→letter confusion in position 1 (1→I, 0→O, 5→S, 2→Z, 8→B)
- Missing dot separator (F050→F05.0, R410→R41.0)
- '+' instead of '.' (B99+1→B99.1, J961+0→J96.10)
- Excess decimals (Z04.880→Z04.88)
- OCR letter→digit in positions 2-3 (LO2.2→L02.2)
- Literal "null" string purge
- Auto-fill codes_retenus from decision context

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-23 20:44:32 +01:00
commit f70d138db3
13 changed files with 1699 additions and 0 deletions

248
extractor/normalizer.py Normal file
View File

@@ -0,0 +1,248 @@
"""
Post-traitement déterministe des extractions OGC.
Corrige les erreurs OCR sur les codes CIM-10, remplit les champs manquants,
et normalise les données avant validation.
"""
import re
import logging
logger = logging.getLogger(__name__)
# Pattern CIM-10 valide : lettre majuscule + 2 chiffres + optionnel .1-2 chiffres
CIM10_PATTERN = re.compile(r'^[A-Z]\d{2}(?:\.\d{1,2})?$')
# Mapping OCR chiffre → lettre CIM-10 (position 1)
# L'OCR confond fréquemment ces lettres avec des chiffres
OCR_DIGIT_TO_LETTER = {
'1': 'I', # Chapitre I : Appareil circulatoire (I00-I99)
'0': 'O', # Chapitre O : Grossesse (O00-O99)
'5': 'S', # Chapitre S : Traumatismes (S00-S99)
'2': 'Z', # Chapitre Z : Facteurs influençant la santé (Z00-Z99)
'8': 'B', # Chapitre B : Infections (B00-B99)
}
def _fix_cim10_format(code: str) -> str:
"""
Corrige les problèmes de formatage d'un code CIM-10 :
- '+' au lieu de '.' (OCR/LLM confusion) : B99+1 → B99.1, J961+0 → J96.10
- Point manquant : F050 → F05.0, F0110 → F01.10, R410 → R41.0
- Point mal positionné : J961.0 → J96.10 (après remplacement +→.)
- Trop de décimales : Z04.880 → Z04.88 (max 2 après le point)
- OCR lettre→chiffre en positions 2-3 : LO2.2 → L02.2, LI3.3 → L13.3
"""
# OCR lettre→chiffre dans les positions qui doivent être des chiffres (pos 1, 2)
# Ex: LO2.2 → L02.2 (O lu au lieu de 0), LI3.3 → L13.3 (I lu au lieu de 1)
OCR_LETTER_TO_DIGIT = {'O': '0', 'I': '1', 'S': '5', 'Z': '2', 'B': '8'}
if len(code) >= 3 and code[0].isalpha():
chars = list(code)
fixed = False
for pos in (1, 2):
if chars[pos] in OCR_LETTER_TO_DIGIT:
chars[pos] = OCR_LETTER_TO_DIGIT[chars[pos]]
fixed = True
if fixed:
code = ''.join(chars)
# Remplacer '+' par '.' (confusion OCR fréquente)
if '+' in code:
code = code.replace('+', '.')
# Point mal positionné : lettre + 3+ chiffres + point + chiffres → repositionner
# Ex: J961.0 (de J961+0) → J96.10
m = re.match(r'^([A-Z])(\d{3,})\.(\d+)$', code)
if m:
letter = m.group(1)
all_digits = m.group(2) + m.group(3)
code = letter + all_digits[:2] + '.' + all_digits[2:]
# Point manquant : lettre + 3-4 chiffres sans point → insérer le point après pos 3
if re.match(r'^[A-Z]\d{3,4}$', code):
code = code[:3] + '.' + code[3:]
# Trop de décimales : tronquer à 2 chiffres après le point
m = re.match(r'^([A-Z]\d{2}\.)(\d{3,})$', code)
if m:
code = m.group(1) + m.group(2)[:2]
return code
def normalize_cim10_code(code: str) -> tuple[str, bool]:
"""
Corrige un code CIM-10 :
1. Formatage (point manquant, '+''.', décimales excédentaires)
2. Confusion OCR chiffre/lettre en position 1
Retourne (code_corrigé, a_été_corrigé).
"""
code = code.strip()
if not code:
return code, False
# Déjà valide → ne rien faire
if CIM10_PATTERN.match(code):
return code, False
original = code
# Étape 1 : corriger le formatage (point, +, décimales)
code = _fix_cim10_format(code)
if CIM10_PATTERN.match(code):
return code, code != original
# Étape 2 : corriger la confusion OCR chiffre → lettre en position 1
if code[0] in OCR_DIGIT_TO_LETTER:
candidate = OCR_DIGIT_TO_LETTER[code[0]] + code[1:]
if CIM10_PATTERN.match(candidate):
return candidate, True
# Étape 3 : combiner les deux (formatage puis OCR)
# Ex: 1500 → _fix_format → 150.0 → OCR → I50.0 (peu probable mais safe)
if original[0] in OCR_DIGIT_TO_LETTER:
reformatted = _fix_cim10_format(OCR_DIGIT_TO_LETTER[original[0]] + original[1:])
if CIM10_PATTERN.match(reformatted):
return reformatted, True
return original, False
def normalize_codes_field(codes_str: str | None) -> tuple[str | None, list[str]]:
"""
Applique la correction CIM-10 à chaque code d'une chaîne séparée par virgules.
Retourne (chaîne_corrigée, liste_des_corrections).
"""
if not codes_str:
return codes_str, []
# Purger le littéral "null" (le LLM écrit parfois le mot au lieu de ne rien mettre)
if codes_str.strip().lower() == 'null':
return None, ["'null' (littéral) → supprimé"]
codes = [c.strip() for c in codes_str.split(',')]
corrections = []
normalized = []
for code in codes:
if not code:
continue
# Purger les "null" individuels dans une liste de codes
if code.lower() == 'null':
corrections.append(f"'{code}' → supprimé")
continue
new_code, was_fixed = normalize_cim10_code(code)
if was_fixed:
corrections.append(f"'{code}''{new_code}'")
normalized.append(new_code)
result = ', '.join(normalized) if normalized else None
return result, corrections
def autofill_codes_retenus(extraction) -> list[str]:
"""
Quand codes_retenus est vide, le remplit selon la décision :
- Défavorable → copie codes_controleurs
- Favorable → copie codes_etablissement
Retourne la liste des corrections appliquées.
"""
fixes = []
if extraction.codes_retenus:
return fixes
if extraction.decision_ucr == "Défavorable" and extraction.codes_controleurs:
extraction.codes_retenus = extraction.codes_controleurs
fixes.append(f"codes_retenus auto-rempli depuis codes_controleurs (Défavorable)")
elif extraction.decision_ucr == "Favorable" and extraction.codes_etablissement:
extraction.codes_retenus = extraction.codes_etablissement
fixes.append(f"codes_retenus auto-rempli depuis codes_etablissement (Favorable)")
return fixes
def normalize_extraction(extraction) -> list[str]:
"""
Orchestre toutes les normalisations sur un OGCExtraction.
Retourne la liste des corrections appliquées.
"""
if not extraction.extraction_success:
return []
all_fixes = []
# 1. Normaliser les codes CIM-10
for field in ('codes_etablissement', 'codes_controleurs', 'codes_retenus'):
value = getattr(extraction, field)
new_value, corrections = normalize_codes_field(value)
if corrections:
setattr(extraction, field, new_value)
for c in corrections:
all_fixes.append(f"{field}: {c}")
# 2. Auto-remplir codes_retenus si vide
all_fixes.extend(autofill_codes_retenus(extraction))
# 3. Fallback texte_decision depuis _raw_block_text
raw_text = getattr(extraction, '_raw_block_text', None)
if raw_text and (not extraction.texte_decision or len(extraction.texte_decision.strip()) < 20):
# Chercher le paragraphe DECISION/PROPOSITION UCR dans le texte brut
match = re.search(
r'((?:DECISION|PROPOSITION|Décision|D[ée]cision)\s+UCR[:\s].*?)(?:\n\s*\n|\Z)',
raw_text,
re.DOTALL | re.IGNORECASE,
)
if match and len(match.group(1).strip()) >= 20:
old = extraction.texte_decision or "(vide)"
extraction.texte_decision = match.group(1).strip()
all_fixes.append(f"texte_decision récupéré par regex (était: {old[:30]}...)")
return all_fixes
def normalize_all(extractions: list) -> dict:
"""
Applique normalize_extraction à toute la liste.
Retourne un rapport des corrections :
{
"total_fixes": int,
"details": list[str],
"by_type": {"cim10": int, "codes_retenus": int, "texte_decision": int}
}
"""
all_details = []
by_type = {"cim10": 0, "codes_retenus": 0, "texte_decision": 0}
for ext in extractions:
fixes = normalize_extraction(ext)
if fixes:
ogc_id = f"OGC {ext.num_ogc} (Champ {ext.champ})"
for fix in fixes:
detail = f"{ogc_id} : {fix}"
all_details.append(detail)
logger.info(f" 🔧 {detail}")
# Catégoriser
if "" in fix and ("codes_etablissement" in fix or "codes_controleurs" in fix or "codes_retenus:" in fix):
by_type["cim10"] += 1
elif "codes_retenus auto-rempli" in fix:
by_type["codes_retenus"] += 1
elif "texte_decision" in fix:
by_type["texte_decision"] += 1
total = len(all_details)
if total:
logger.info(f" Normalisation : {total} corrections "
f"(CIM-10: {by_type['cim10']}, codes_retenus: {by_type['codes_retenus']}, "
f"texte_decision: {by_type['texte_decision']})")
else:
logger.info(" Normalisation : aucune correction nécessaire")
return {
"total_fixes": total,
"details": all_details,
"by_type": by_type,
}