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>
249 lines
8.8 KiB
Python
249 lines
8.8 KiB
Python
"""
|
|
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,
|
|
}
|