"""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 from difflib import SequenceMatcher 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()) # Déduplication : supprimer les copies quasi-identiques (destinataires multiples) chunks = _dedup_chunks(chunks) return chunks def _dedup_chunks(chunks: list[str], threshold: float = 0.85) -> list[str]: """Supprime les chunks quasi-identiques (copies pour destinataires multiples). Compare les caractères 200-1000 de chaque paire (en sautant l'en-tête patient qui est identique entre séjours différents du même patient). Si le ratio de similarité > threshold, le doublon est supprimé. """ if len(chunks) <= 1: return chunks duplicates: set[int] = set() for i in range(len(chunks)): if i in duplicates: continue for j in range(i + 1, len(chunks)): if j in duplicates: continue # Comparer le corps du document (après l'en-tête patient) sample_i = chunks[i][200:1000] sample_j = chunks[j][200:1000] # Si un chunk est trop court, comparer ce qui est disponible if len(sample_i) < 50 or len(sample_j) < 50: sample_i = chunks[i] sample_j = chunks[j] ratio = SequenceMatcher(None, sample_i, sample_j).ratio() if ratio > threshold: duplicates.add(j) logger.info(" CRH chunk %d doublon de %d (ratio=%.2f), supprimé", j + 1, i + 1, ratio) return [c for idx, c in enumerate(chunks) if idx not in duplicates]