""" 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, }