File size: 15,280 Bytes
f92da22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
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|(?<!\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
        """
        # 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)