import re import logging from typing import List, Tuple logger = logging.getLogger(__name__) class MedicalReportPostProcessor: """Post-traitement pour nettoyer les rapports médicaux mappés""" def __init__(self): # Patterns pour détecter les lignes avec choix multiples self.choice_patterns = [ # Position utérus r'L\'utérus est(.*?)de taille', # Adénomyose r'Adénomyose associée :(.*?)(?:\n|$)', r'&x oui :(.*?)(?:\n|Col utérin)', # Accessibilité ovaires r'Accessibilité :(.*?)(?:\n|\t|$)', # Compartiments r'Signe du glissement \(sliding\) :(.*?)(?:\n|$)', # Nodules r'Présence d\'un nodule :(.*?)(?:\n|$)', r'Présence d\'un nodule hypoéchogène :(.*?)(?:\n|$)', # Aspect r'Aspect du torus\s*:(.*?)(?:\n|$)', r'Ligament utéro-.*?:(.*?)(?:\n|$)', # Infiltration r'Infiltration digestive:(.*?)(?:\n|$)', # Endométriose r'Endométriose(.*?)(?:\n|Absence)', # Épanchement r'- (Pas d\'épanchement.*?|Faible épanchement.*?)(?:\n|$)', # Vessie r'- (.*?Vessie.*?)(?:\n|$)', # Dilatation r'- (.*?dilatation.*?)(?:\n|$)', # Calcifications r'Présence de micro-calcifications(.*?)(?:\n|L\'échostructure)', # Ovaires dimensions supérieures r', (.*?est de dimensions supérieures.*?)(?:,|\n)', # Endométriome r'\. (.*?endométriome\.)(?:\n|$)', ] def process_report(self, report: str) -> str: """ Traite le rapport complet pour nettoyer les choix multiples """ logger.info("🧹 Début du post-traitement du rapport") processed_report = report # Étape 1: Nettoyer les lignes avec choix multiples processed_report = self._clean_multiple_choices(processed_report) # Étape 2: Nettoyer les placeholders isolés restants processed_report = self._clean_isolated_placeholders(processed_report) # Étape 3: Nettoyer les espaces et formatage processed_report = self._clean_formatting(processed_report) logger.info("✅ Post-traitement terminé") return processed_report def _clean_multiple_choices(self, text: str) -> str: """ Nettoie les lignes contenant plusieurs choix (&x ou X) Ne garde que les options cochées (X) """ lines = text.split('\n') cleaned_lines = [] for line in lines: # Vérifier si la ligne contient des choix multiples if self._has_multiple_choices(line): cleaned_line = self._extract_checked_choices(line) cleaned_lines.append(cleaned_line) else: cleaned_lines.append(line) return '\n'.join(cleaned_lines) def _has_multiple_choices(self, line: str) -> bool: """ Détecte si une ligne contient plusieurs choix (au moins 2 occurrences de &x ou X suivi d'un mot) """ # Compter les patterns de choix: &x ou X suivi d'un mot pattern = r'(?:&x|(?= 2 def _extract_checked_choices(self, line: str) -> str: """ Extrait uniquement les choix cochés (X) d'une ligne """ # Séparer la partie avant les choix et après parts = self._split_line_by_choices(line) if not parts: return line prefix = parts['prefix'] choices = parts['choices'] suffix = parts['suffix'] # Extraire les choix cochés checked_choices = [] for choice in choices: if choice.strip().startswith('X '): # Enlever le X et garder le texte checked_text = choice.strip()[2:].strip() checked_choices.append(checked_text) # Reconstruire la ligne if checked_choices: result = prefix if len(checked_choices) == 1: result += checked_choices[0] else: result += ', '.join(checked_choices) result += suffix return result else: # Si aucun choix coché, retourner la ligne d'origine return line def _split_line_by_choices(self, line: str) -> dict: """ Sépare une ligne en: préfixe, choix, suffixe """ # Cas spécifiques avec patterns connus # Position utérus match = re.search(r'(L\'utérus est\s+)((?:[X&]x?\s+\w+[,\s]+)+)(de taille.*)', line) if match: return { 'prefix': match.group(1), 'choices': self._parse_choices(match.group(2)), 'suffix': ' ' + match.group(3) } # Adénomyose associée match = re.search(r'(Adénomyose associée\s*:\s*)((?:[X&]x?\s+\w+\s*)+)(.*)', line) if match: return { 'prefix': match.group(1), 'choices': self._parse_choices(match.group(2)), 'suffix': match.group(3) } # Type d'adénomyose match = re.search(r'([X&]x?\s+oui\s*:\s*)((?:[X&]x?\s+\w+\s*)+)(.*)', line) if match: return { 'prefix': '', 'choices': self._parse_choices(match.group(2)), 'suffix': match.group(3) } # Accessibilité match = re.search(r'(Accessibilité\s*:\s*)((?:[X&]x?\s+[\w-]+\s*)+)(.*)', line) if match: return { 'prefix': match.group(1), 'choices': self._parse_choices(match.group(2)), 'suffix': match.group(3) } # Signe du glissement match = re.search(r'(.*?Signe du glissement.*?:\s*)((?:[X&]x?\s*\w+\s*)+)(.*)', line) if match: return { 'prefix': match.group(1), 'choices': self._parse_choices(match.group(2)), 'suffix': match.group(3) } # Présence nodule match = re.search(r'(.*?Présence d\'un nodule.*?:\s*)((?:[X&]x?\s*\w+\s*)+)(.*)', line) if match: return { 'prefix': match.group(1), 'choices': self._parse_choices(match.group(2)), 'suffix': match.group(3) } # Aspect match = re.search(r'(.*?Aspect.*?:\s*)((?:[X&]x?\s+\w+\s*)+)(.*)', line) if match: return { 'prefix': match.group(1), 'choices': self._parse_choices(match.group(2)), 'suffix': match.group(3) } # Infiltration digestive match = re.search(r'(.*?Infiltration digestive:\s*)((?:[X&]x?\s+\w+\s*)+:\s*)((?:[X&]x?\s+[\w\s-]+)+)(.*)', line) if match: # Gérer le cas spécial avec "non/oui :" first_choice = self._parse_choices(match.group(2)) second_choices = self._parse_choices(match.group(3)) return { 'prefix': match.group(1), 'choices': first_choice + second_choices, 'suffix': match.group(4) } # Calcifications match = re.search(r'(.*?micro-calcifications.*?)((?:[X&]x?\s+\w+\s*)+)(.*)', line) if match: return { 'prefix': match.group(1), 'choices': self._parse_choices(match.group(2)), 'suffix': match.group(3) } # Endométriose match = re.search(r'([X&]x?\s+Endométriose\s+)((?:[X&]x?\s+\w+\s*)+)(.*)', line) if match: return { 'prefix': 'Endométriose ', 'choices': self._parse_choices(match.group(2)), 'suffix': match.group(3) } # Épanchement (début par -) match = re.search(r'^(\s*-?\s*)([X&]x?-?\s*(?:Pas|Faible).*?)$', line) if match: text = match.group(2) if text.strip().startswith('X'): return { 'prefix': match.group(1), 'choices': ['X ' + text[1:].strip()], 'suffix': '' } # Vessie match = re.search(r'^(\s*-\s*)([X&]x?\s*Vessie.*?)([X&]x?\s*Vessie.*?)$', line) if match: choices = [] if match.group(2).strip().startswith('X'): choices.append('X ' + match.group(2)[1:].strip()) if match.group(3).strip().startswith('&x'): pass # Ne rien ajouter elif match.group(3).strip().startswith('X'): choices.append('X ' + match.group(3)[1:].strip()) return { 'prefix': match.group(1), 'choices': choices, 'suffix': '' } return None def _parse_choices(self, choices_text: str) -> List[str]: """ Parse le texte des choix pour extraire chaque option """ # Séparer par &x ou X en début de mot parts = re.split(r'(?=[X&]x?\s+)', choices_text) return [p.strip() for p in parts if p.strip()] def _clean_isolated_placeholders(self, text: str) -> str: """ Nettoie les placeholders &x isolés qui restent """ # Supprimer les &x en début de ligne ou après espace text = re.sub(r'^\s*&x\s*', '', text, flags=re.MULTILINE) text = re.sub(r'\s+&x\s+', ' ', text) text = re.sub(r'\s+&x$', '', text, flags=re.MULTILINE) # Nettoyer les lignes qui ne contiennent que des &x lines = text.split('\n') cleaned_lines = [] for line in lines: # Si la ligne ne contient que des &x et espaces, la supprimer if re.match(r'^\s*(?:&x\s*)+$', line): continue cleaned_lines.append(line) return '\n'.join(cleaned_lines) def _clean_formatting(self, text: str) -> str: """ Nettoie le formatage général """ # Supprimer les espaces multiples text = re.sub(r' +', ' ', text) # Supprimer les lignes vides multiples text = re.sub(r'\n\s*\n\s*\n+', '\n\n', text) # Nettoyer les espaces avant ponctuation text = re.sub(r' +([,.])', r'\1', text) # Nettoyer les espaces après tirets en début de ligne text = re.sub(r'^(\s*-)\s+', r'\1 ', text, flags=re.MULTILINE) return text.strip() def post_process_medical_report(filled_template: str) -> str: """ Fonction principale pour post-traiter un rapport médical """ processor = MedicalReportPostProcessor() return processor.process_report(filled_template) # Exemple d'utilisation if __name__ == "__main__": # Exemple de rapport avec choix multiples sample_report = """L'utérus est X antéversé, &x rétroversé, &x intermédiaire, &x rétrofléchi, &x antéfléchi, &x fixe de taille normale (7.8 x &x x &x cm). Hystérométrie : distance orifice externe du col - fond de la cavité utérine : 60 mm. L'endomètre : mesuré à 3.7 mm. Myometre : pas de myome. Zone jonctionnelle : Atteinte de la zone de jonction : &x non &x oui Adénomyose associée : &x non X oui : X diffuse &x focale &x interne &x externe Col utérin: pas de kyste de Naboth. Absence de pathologies échographiquement décelable à son niveau. Cavité utérine en 3D: morphologie triangulaire. L'ovaire droit mesure 26 x 20 mm, &x est de dimensions supérieures à la normale il mesure &x x &x mm, &xfolliculaire CFA 15 follicules: (&x mm). &x Absence d'endométriome. &x Présence d'une formation kystique hypoéchogène, uniloculaire, non vascularisé, à contenu ground glass mesurée à &x mm d'allure endométriome. Accessibilité : &x rétro-utérin &x fixe X aisée. L'ovaire gauche mesure 25 x 19 mm, &x est de dimensions supérieures à la normale il mesure &x x &x mm, &x folliculaire CFA 22 follicules: (&x mm). &x Absence d'endométriome. &x Présence d'une formation kystique hypoéchogène, uniloculaire, non vascularisé, à contenu ground glass mesurée à &x mm d'allure endométriome. Accessibilité : X rétro-utérin &x fixe &x aisée. &x Présence de micro-calcifications sous thécales &x bilatérales &x droites &x gauches pouvant témoigner d'implants endométriosiques superficiels. L'échostructure des deux ovaires apparait normale, avec une vascularisation artério-veineuse normale au Doppler, sans formation ou image kystique pathologique échographiquement décelable à leur niveau. Cavité péritonéale &x- Pas d'épanchement liquidien dans le cul du sac du Douglas. Pas de douleur à l'écho-palpation. &x- Faible épanchement corpusculé dans le cul du sac du Douglas qui silhouette des adhérences (soft marqueur d'endométriose?). Pas de douleur à l'écho-palpation. - XVessie vide pendant l'examen. &x Vessie en semi-réplétion pendant l'examen. - X Absence de dilatation pyélo-calicielle. - Artère utérine : IP : 3.24 - IR : 0,91 - Spectre : type 2 avec notch protodiastolique. - Pas d'image d'hydrosalpinx visible à ce jour. RECHERCHE ENDOMETRIOSE PELVIENNE A-Compartiment antérieur (vessie en semi-réplétion) - Signe du glissement (sliding) : &xprésent &xdiminué &xabsent - Présence d'un nodule : &xnon &xoui - Uretères dans la partie pelvienne vus non dilatés. B-Compartiment postérieur - Signe du glissement (sliding) : - Espace recto-vaginal : &xprésent &xdiminué &xabsent - Plan sus-péritonéal : &xprésent &xdiminué &xabsent - Aspect du torus : &x normal &x épaissi - Aspect des ligaments utéro-sacrés : - Ligament utéro- sacré droit : &x normal &x épaissi - Ligament utéro-sacré gauche : &x normal &x épaissi - Présence d'un nodule hypoéchogène : &x non - Infiltration digestive: &x non X oui : &x bas rectum &x moyen rectum &x haut rectum &x jonction recto-sigmoïde Conclusions Utérus de taille et de morphologie normales. Endomètre mesuré à 3.7 mm. CFA : 15+22 follicules. Ovaires sans formation ou image kystique pathologique échographiquement décelable à leur niveau. X Absence d'image d'endométriose visible ce jour, à confronter éventuellement à une IRM. &x Endométriose &x superficielle &x et profonde. Absence d'anomalie échographiquement décelable au niveau des trompes. --> L'ensemble de ces aspects reste à confronter au contexte clinico-thérapeutique.""" # Appliquer le post-traitement cleaned_report = post_process_medical_report(sample_report) print("=" * 60) print("RAPPORT APRÈS POST-TRAITEMENT") print("=" * 60) print(cleaned_report)