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) |