From a00e5f114725bc53ae76802015b0a51add729933 Mon Sep 17 00:00:00 2001 From: dom Date: Thu, 12 Feb 2026 09:08:37 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20d=C3=A9coupage=20PDFs=20multi-dossiers?= =?UTF-8?q?=20(Trackare=20multi-=C3=A9pisodes,=20CRH=20concat=C3=A9n=C3=A9?= =?UTF-8?q?s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute une étape de splitting entre extraction texte et parsing. Chaque chunk est traité indépendamment par le pipeline existant, avec suffixe _partN en sortie. Co-Authored-By: Claude Opus 4.6 --- src/extraction/document_splitter.py | 124 ++++++++++++++++ src/main.py | 90 +++++++----- tests/test_splitter.py | 218 ++++++++++++++++++++++++++++ 3 files changed, 397 insertions(+), 35 deletions(-) create mode 100644 src/extraction/document_splitter.py create mode 100644 tests/test_splitter.py diff --git a/src/extraction/document_splitter.py b/src/extraction/document_splitter.py new file mode 100644 index 0000000..fa8e3da --- /dev/null +++ b/src/extraction/document_splitter.py @@ -0,0 +1,124 @@ +"""Découpage de PDFs multi-dossiers en chunks indépendants. + +Certains PDFs contiennent plusieurs séjours/épisodes : +- Trackare : plusieurs Episode No dans un même export +- CRH : plusieurs lettres de sortie concaténées + +Ce module insère une étape de splitting entre l'extraction texte et le parsing. +Chaque chunk est ensuite traité indépendamment par le pipeline existant. +""" + +from __future__ import annotations + +import re +import logging + +logger = logging.getLogger(__name__) + + +def split_documents(text: str, doc_type: str) -> list[str]: + """Point d'entrée : découpe un texte en chunks selon le type de document. + + Retourne toujours au moins [text] (pas de split si un seul dossier). + """ + if doc_type == "trackare": + return _split_trackare(text) + elif doc_type == "crh": + return _split_crh(text) + return [text] + + +def _split_trackare(text: str) -> list[str]: + """Découpe un export Trackare multi-épisodes. + + Stratégie : + 1. Compter les occurrences de "Episode No:" + 2. Si une seule → pas de split + 3. Si plusieurs → couper sur "Détails épisode" (ou second "Episode No:") + 4. Préfixer le bloc patient à chaque chunk + """ + episodes = list(re.finditer(r"Episode No:\s*\d+", text)) + if len(episodes) <= 1: + return [text] + + logger.info(" Trackare multi-épisodes détecté : %d épisodes", len(episodes)) + + # Identifier le bloc patient (avant le premier épisode/détails épisode) + # Le bloc patient va du début jusqu'à "Détails épisode" ou le premier "Episode No:" + first_episode_start = episodes[0].start() + + # Chercher "Détails épisode" qui précède chaque bloc épisode + details_markers = list(re.finditer(r"Détails épisode", text)) + + if len(details_markers) >= 2: + # Couper sur "Détails épisode" + split_points = [m.start() for m in details_markers] + # Le bloc patient = tout avant le premier "Détails épisode" + patient_block = text[:split_points[0]].rstrip() + else: + # Fallback : couper sur "Episode No:" + split_points = [m.start() for m in episodes] + # Le bloc patient = tout avant le premier "Episode No:" + # Remonter pour inclure "Détails épisode" s'il existe avant + if details_markers: + patient_block = text[:details_markers[0].start()].rstrip() + else: + patient_block = text[:split_points[0]].rstrip() + + chunks: list[str] = [] + for i, start in enumerate(split_points): + end = split_points[i + 1] if i + 1 < len(split_points) else len(text) + episode_text = text[start:end].rstrip() + # Préfixer le bloc patient pour que le parser ait les infos complètes + chunk = patient_block + "\n\n" + episode_text + chunks.append(chunk) + + return chunks + + +def _split_crh(text: str) -> list[str]: + """Découpe un PDF contenant plusieurs CRH concaténés. + + Stratégie : + 1. Détecter les frontières par headers patient (MME|M\\.|MR) suivis de + patterns CRH (dates séjour, "Mon cher confrère") + 2. Si une seule occurrence → pas de split + 3. Si plusieurs → couper sur chaque header patient + """ + # Chercher les headers patient typiques d'un début de CRH + # On cherche le pattern complet : titre + nom en majuscules + headers = list(re.finditer( + r"(?:^|\n)(?=\s*(?:MME|M\.|MR)\s+[A-ZÉÈÊËÀÂ]{2,})", + text, + )) + + if len(headers) <= 1: + return [text] + + # Filtrer : ne garder que les headers qui sont vraiment des débuts de CRH + # (suivis dans les 2000 chars par un pattern CRH typique) + crh_starts: list[int] = [] + for h in headers: + pos = h.start() + # Sauter le \n initial si présent + if text[pos:pos + 1] == "\n": + pos += 1 + lookahead = text[pos:pos + 2000].lower() + if (re.search(r"du\s+\d{2}/\d{2}/\d{4}\s+au\s+\d{2}/\d{2}/\d{4}", lookahead) + or "mon cher confrère" in lookahead + or "cher confrère" in lookahead + or "chère consœur" in lookahead + or "compte rendu" in lookahead): + crh_starts.append(pos) + + if len(crh_starts) <= 1: + return [text] + + logger.info(" CRH multi-documents détecté : %d CRH", len(crh_starts)) + + chunks: list[str] = [] + for i, start in enumerate(crh_starts): + end = crh_starts[i + 1] if i + 1 < len(crh_starts) else len(text) + chunks.append(text[start:end].rstrip()) + + return chunks diff --git a/src/main.py b/src/main.py index 6c6bf0d..6b3dedd 100644 --- a/src/main.py +++ b/src/main.py @@ -13,6 +13,7 @@ from .anonymization.anonymizer import Anonymizer from .config import ANONYMIZED_DIR, REPORTS_DIR, STRUCTURED_DIR, AnonymizationReport, DossierMedical from .extraction.document_classifier import classify from .extraction.crh_parser import parse_crh +from .extraction.document_splitter import split_documents from .extraction.pdf_extractor import extract_text from .extraction.trackare_parser import parse_trackare from .medical.cim10_extractor import extract_medical_info @@ -28,8 +29,11 @@ _use_edsnlp = True _use_rag = True -def process_pdf(pdf_path: Path) -> tuple[str, DossierMedical, AnonymizationReport]: - """Traite un PDF : extraction → parsing → anonymisation → extraction CIM-10.""" +def process_pdf(pdf_path: Path) -> list[tuple[str, DossierMedical, AnonymizationReport]]: + """Traite un PDF : extraction → splitting → parsing → anonymisation → extraction CIM-10. + + Retourne une liste de (texte_anonymisé, dossier, rapport) — un par dossier détecté. + """ t0 = time.time() logger.info("Traitement de %s", pdf_path.name) @@ -41,40 +45,53 @@ def process_pdf(pdf_path: Path) -> tuple[str, DossierMedical, AnonymizationRepor doc_type = classify(raw_text) logger.info(" Type de document : %s", doc_type) - # 3. Parsing - if doc_type == "trackare": - parsed = parse_trackare(raw_text) - else: - parsed = parse_crh(raw_text) + # 3. Splitting multi-dossiers + chunks = split_documents(raw_text, doc_type) + if len(chunks) > 1: + logger.info(" Découpage : %d dossiers détectés dans %s", len(chunks), pdf_path.name) - # 4. Anonymisation - anonymizer = Anonymizer(parsed_data=parsed) - anonymized_text = anonymizer.anonymize(raw_text) - report = anonymizer.report - report.source_file = pdf_path.name - logger.info( - " Anonymisation : %d remplacements (regex=%d, ner=%d, sweep=%d)", - report.total_replacements, - report.regex_replacements, - report.ner_replacements, - report.sweep_replacements, - ) + results: list[tuple[str, DossierMedical, AnonymizationReport]] = [] + for i, chunk_text in enumerate(chunks): + part_label = f" [part {i+1}/{len(chunks)}]" if len(chunks) > 1 else "" + logger.info(" Traitement%s...", part_label) - # 5. Analyse edsnlp (optionnelle) - edsnlp_result = None - if _use_edsnlp: - edsnlp_result = _run_edsnlp(anonymized_text) + # 4. Parsing + if doc_type == "trackare": + parsed = parse_trackare(chunk_text) + else: + parsed = parse_crh(chunk_text) - # 6. Extraction médicale CIM-10 - dossier = extract_medical_info(parsed, anonymized_text, edsnlp_result, use_rag=_use_rag) - dossier.source_file = pdf_path.name - dossier.document_type = doc_type - dossier.processing_time_s = round(time.time() - t0, 2) - logger.info(" DP : %s", dossier.diagnostic_principal) - logger.info(" DAS : %d, Actes : %d", len(dossier.diagnostics_associes), len(dossier.actes_ccam)) - logger.info(" Temps de traitement : %.2fs", dossier.processing_time_s) + # 5. Anonymisation + anonymizer = Anonymizer(parsed_data=parsed) + anonymized_text = anonymizer.anonymize(chunk_text) + report = anonymizer.report + report.source_file = pdf_path.name + logger.info( + " Anonymisation%s : %d remplacements (regex=%d, ner=%d, sweep=%d)", + part_label, + report.total_replacements, + report.regex_replacements, + report.ner_replacements, + report.sweep_replacements, + ) - return anonymized_text, dossier, report + # 6. Analyse edsnlp (optionnelle) + edsnlp_result = None + if _use_edsnlp: + edsnlp_result = _run_edsnlp(anonymized_text) + + # 7. Extraction médicale CIM-10 + dossier = extract_medical_info(parsed, anonymized_text, edsnlp_result, use_rag=_use_rag) + dossier.source_file = pdf_path.name + dossier.document_type = doc_type + dossier.processing_time_s = round(time.time() - t0, 2) + logger.info(" DP%s : %s", part_label, dossier.diagnostic_principal) + logger.info(" DAS : %d, Actes : %d", len(dossier.diagnostics_associes), len(dossier.actes_ccam)) + + results.append((anonymized_text, dossier, report)) + + logger.info(" Temps total : %.2fs", time.time() - t0) + return results def _run_edsnlp(text: str): @@ -252,10 +269,13 @@ def main(input_path: str | None = None) -> None: group_dossiers: list[DossierMedical] = [] for pdf_path in pdfs: try: - anonymized_text, dossier, report = process_pdf(pdf_path) + pdf_results = process_pdf(pdf_path) stem = pdf_path.stem.replace(" ", "_") - write_outputs(stem, anonymized_text, dossier, report, subdir=subdir) - group_dossiers.append(dossier) + multi = len(pdf_results) > 1 + for part_idx, (anonymized_text, dossier, report) in enumerate(pdf_results): + part_stem = f"{stem}_part{part_idx + 1}" if multi else stem + write_outputs(part_stem, anonymized_text, dossier, report, subdir=subdir) + group_dossiers.append(dossier) except Exception: logger.exception("Erreur lors du traitement de %s", pdf_path.name) diff --git a/tests/test_splitter.py b/tests/test_splitter.py new file mode 100644 index 0000000..b8e3f23 --- /dev/null +++ b/tests/test_splitter.py @@ -0,0 +1,218 @@ +"""Tests pour le module de découpage multi-dossiers.""" + +from unittest.mock import patch, MagicMock + +import pytest + +from src.extraction.document_splitter import split_documents, _split_trackare, _split_crh + + +# --- Données de test --- + +TRACKARE_PATIENT_BLOCK = """CENTRE HOSPITALIER COTE BASQUE +Dossier Patient +Détails des patients +Nom de naissance: DUPONT IPP: 01234567 +Nom et Prénom: DUPONT JEAN Date de naissance: 15/03/1960 +Sexe: Masculin Lieu de naissance: BAYONNE +Adresse: 12 RUE DU PORT Ville de résidence: BAYONNE +Code Postal: 64100""" + +TRACKARE_EPISODE_1 = """Détails épisode +Episode No: 23001111 +Date d'admission: 10/01/2023 Heure d'admission: 14:30 +Date de sortie: 10/01/2023 Heure de sortie: 18:00 +Localisation: URG-ADULTE Médecin courant: DR MARTIN +Observations médicales +Note d'évolution DR MARTIN 10/01/2023 15:00 +Douleur abdominale. Bilan normal. +Diagnostic aux urgences +Principal actif R10.4 Douleurs abdominales 10/01/2023 15:30""" + +TRACKARE_EPISODE_2 = """Détails épisode +Episode No: 23002222 +Date d'admission: 15/03/2023 Heure d'admission: 09:15 +Date de sortie: 15/03/2023 Heure de sortie: 13:00 +Localisation: URG-ADULTE Médecin courant: DR DURAND +Observations médicales +Note d'évolution DR DURAND 15/03/2023 10:00 +Chute mécanique. Radio cheville gauche normale. +Diagnostic aux urgences +Principal actif S93.4 Entorse de la cheville 15/03/2023 10:30""" + +TRACKARE_SINGLE = TRACKARE_PATIENT_BLOCK + "\n" + TRACKARE_EPISODE_1 +TRACKARE_MULTI = TRACKARE_PATIENT_BLOCK + "\n" + TRACKARE_EPISODE_1 + "\n" + TRACKARE_EPISODE_2 + + +CRH_SINGLE = """MME MARTIN SOPHIE +12 RUE DES FLEURS +64100 BAYONNE + +Mon cher confrère, +Votre patiente MARTIN Sophie née le 05/06/1975 a été hospitalisée +du 20/01/2023 au 25/01/2023 pour le motif suivant: +Cholécystite aiguë lithiasique. + +Cordialement, +Dr DURAND""" + +CRH_DOC_1 = """MME MARTIN SOPHIE +12 RUE DES FLEURS +64100 BAYONNE + +Mon cher confrère, +Votre patiente MARTIN Sophie née le 05/06/1975 a été hospitalisée +du 20/01/2023 au 25/01/2023 pour le motif suivant: +Cholécystite aiguë lithiasique. + +Cordialement, +Dr DURAND +""" + +CRH_DOC_2 = """M. BERNARD PIERRE +5 AVENUE DU MARÉCHAL +64200 BIARRITZ + +Mon cher confrère, +Votre patient BERNARD Pierre né le 12/11/1950 a été hospitalisé +du 02/02/2023 au 08/02/2023 pour le motif suivant: +Décompensation cardiaque. + +Cordialement, +Dr PUJOS +""" + +CRH_MULTI = CRH_DOC_1 + CRH_DOC_2 + + +# --- Tests Trackare --- + +class TestSplitTrackare: + def test_single_episode_returns_unchanged(self): + result = _split_trackare(TRACKARE_SINGLE) + assert len(result) == 1 + assert result[0] == TRACKARE_SINGLE + + def test_multi_episode_returns_two_chunks(self): + result = _split_trackare(TRACKARE_MULTI) + assert len(result) == 2 + + def test_each_chunk_has_patient_block(self): + result = _split_trackare(TRACKARE_MULTI) + for chunk in result: + assert "DUPONT JEAN" in chunk + assert "IPP: 01234567" in chunk + + def test_each_chunk_has_own_episode(self): + result = _split_trackare(TRACKARE_MULTI) + assert "Episode No: 23001111" in result[0] + assert "Episode No: 23002222" in result[1] + + def test_episodes_are_separated(self): + result = _split_trackare(TRACKARE_MULTI) + # Le premier chunk ne doit PAS contenir l'épisode 2 + assert "Episode No: 23002222" not in result[0] + # Le second chunk ne doit PAS contenir l'épisode 1 + assert "Episode No: 23001111" not in result[1] + + def test_no_episode_returns_unchanged(self): + text = "Un texte sans épisode du tout." + result = _split_trackare(text) + assert len(result) == 1 + assert result[0] == text + + def test_fallback_without_details_episode(self): + """Si pas de 'Détails épisode', coupe sur 'Episode No:'.""" + text = ( + "Nom de naissance: DUPONT IPP: 01234567\n" + "Episode No: 111\nContenu épisode 1\n" + "Episode No: 222\nContenu épisode 2" + ) + result = _split_trackare(text) + assert len(result) == 2 + assert "Episode No: 111" in result[0] + assert "Episode No: 222" in result[1] + + +# --- Tests CRH --- + +class TestSplitCRH: + def test_single_crh_returns_unchanged(self): + result = _split_crh(CRH_SINGLE) + assert len(result) == 1 + assert result[0] == CRH_SINGLE + + def test_multi_crh_returns_two_chunks(self): + result = _split_crh(CRH_MULTI) + assert len(result) == 2 + + def test_each_chunk_is_independent(self): + result = _split_crh(CRH_MULTI) + assert "MARTIN SOPHIE" in result[0] + assert "BERNARD PIERRE" in result[1] + + def test_chunks_contain_medical_content(self): + result = _split_crh(CRH_MULTI) + assert "Cholécystite" in result[0] + assert "Décompensation cardiaque" in result[1] + + def test_no_header_returns_unchanged(self): + text = "Un texte médical sans header patient." + result = _split_crh(text) + assert len(result) == 1 + + def test_header_without_crh_markers_no_split(self): + """Un header patient sans patterns CRH ne déclenche pas de split.""" + text = "MME DUPONT MARIE\nTexte quelconque\nMME MARTIN SOPHIE\nAutre texte" + result = _split_crh(text) + assert len(result) == 1 + + +# --- Tests dispatch --- + +class TestSplitDocuments: + def test_dispatch_trackare(self): + result = split_documents(TRACKARE_MULTI, "trackare") + assert len(result) == 2 + + def test_dispatch_crh(self): + result = split_documents(CRH_MULTI, "crh") + assert len(result) == 2 + + def test_dispatch_unknown_type(self): + result = split_documents("some text", "unknown") + assert len(result) == 1 + assert result[0] == "some text" + + def test_single_doc_no_split(self): + result = split_documents(TRACKARE_SINGLE, "trackare") + assert len(result) == 1 + + +# --- Test intégration process_pdf --- + +class TestProcessPdfMulti: + @patch("src.main.extract_text") + @patch("src.main.extract_medical_info") + @patch("src.main._run_edsnlp", return_value=None) + @patch("src.main._use_edsnlp", False) + def test_multi_episode_returns_multiple_results( + self, mock_edsnlp, mock_medical, mock_extract + ): + from pathlib import Path + from src.main import process_pdf + from src.config import DossierMedical, Diagnostic + + # Mock extract_text retournant un texte multi-épisodes Trackare + mock_extract.return_value = TRACKARE_MULTI + + # Mock extract_medical_info retournant un DossierMedical minimal + mock_medical.return_value = DossierMedical( + diagnostic_principal=Diagnostic(texte="test"), + ) + + results = process_pdf(Path("fake.pdf")) + assert len(results) == 2 + # Chaque résultat est un tuple (text, dossier, report) + for anon_text, dossier, report in results: + assert isinstance(dossier, DossierMedical)