| 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): |
| |
| self.choice_patterns = [ |
| |
| r'L\'utérus est(.*?)de taille', |
| |
| r'Adénomyose associée :(.*?)(?:\n|$)', |
| r'&x oui :(.*?)(?:\n|Col utérin)', |
| |
| r'Accessibilité :(.*?)(?:\n|\t|$)', |
| |
| r'Signe du glissement \(sliding\) :(.*?)(?:\n|$)', |
| |
| r'Présence d\'un nodule :(.*?)(?:\n|$)', |
| r'Présence d\'un nodule hypoéchogène :(.*?)(?:\n|$)', |
| |
| r'Aspect du torus\s*:(.*?)(?:\n|$)', |
| r'Ligament utéro-.*?:(.*?)(?:\n|$)', |
| |
| r'Infiltration digestive:(.*?)(?:\n|$)', |
| |
| r'Endométriose(.*?)(?:\n|Absence)', |
| |
| r'- (Pas d\'épanchement.*?|Faible épanchement.*?)(?:\n|$)', |
| |
| r'- (.*?Vessie.*?)(?:\n|$)', |
| |
| r'- (.*?dilatation.*?)(?:\n|$)', |
| |
| r'Présence de micro-calcifications(.*?)(?:\n|L\'échostructure)', |
| |
| r', (.*?est de dimensions supérieures.*?)(?:,|\n)', |
| |
| 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 |
| |
| |
| processed_report = self._clean_multiple_choices(processed_report) |
| |
| |
| processed_report = self._clean_isolated_placeholders(processed_report) |
| |
| |
| 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: |
| |
| 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) |
| """ |
| |
| pattern = r'(?:&x|(?<!\w)X(?=\s+\w))\s+\w+' |
| matches = re.findall(pattern, line) |
| return len(matches) >= 2 |
| |
| def _extract_checked_choices(self, line: str) -> str: |
| """ |
| Extrait uniquement les choix cochés (X) d'une ligne |
| """ |
| |
| parts = self._split_line_by_choices(line) |
| |
| if not parts: |
| return line |
| |
| prefix = parts['prefix'] |
| choices = parts['choices'] |
| suffix = parts['suffix'] |
| |
| |
| checked_choices = [] |
| for choice in choices: |
| if choice.strip().startswith('X '): |
| |
| checked_text = choice.strip()[2:].strip() |
| checked_choices.append(checked_text) |
| |
| |
| if checked_choices: |
| result = prefix |
| if len(checked_choices) == 1: |
| result += checked_choices[0] |
| else: |
| result += ', '.join(checked_choices) |
| result += suffix |
| return result |
| else: |
| |
| return line |
| |
| def _split_line_by_choices(self, line: str) -> dict: |
| """ |
| Sépare une ligne en: préfixe, choix, suffixe |
| """ |
| |
| |
| |
| 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) |
| } |
| |
| |
| 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) |
| } |
| |
| |
| 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) |
| } |
| |
| |
| 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) |
| } |
| |
| |
| 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) |
| } |
| |
| |
| 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) |
| } |
| |
| |
| 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) |
| } |
| |
| |
| match = re.search(r'(.*?Infiltration digestive:\s*)((?:[X&]x?\s+\w+\s*)+:\s*)((?:[X&]x?\s+[\w\s-]+)+)(.*)', line) |
| if match: |
| |
| 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) |
| } |
| |
| |
| 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) |
| } |
| |
| |
| 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) |
| } |
| |
| |
| 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': '' |
| } |
| |
| |
| 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 |
| 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 |
| """ |
| |
| 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 |
| """ |
| |
| 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) |
| |
| |
| lines = text.split('\n') |
| cleaned_lines = [] |
| for line in lines: |
| |
| 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 |
| """ |
| |
| text = re.sub(r' +', ' ', text) |
| |
| |
| text = re.sub(r'\n\s*\n\s*\n+', '\n\n', text) |
| |
| |
| text = re.sub(r' +([,.])', r'\1', text) |
| |
| |
| 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) |
|
|
|
|
| |
| if __name__ == "__main__": |
| |
| 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.""" |
|
|
| |
| cleaned_report = post_process_medical_report(sample_report) |
| |
| print("=" * 60) |
| print("RAPPORT APRÈS POST-TRAITEMENT") |
| print("=" * 60) |
| print(cleaned_report) |