Bloc A: fix sous-parties dans _mappings, filtre NER anti-tag, intégration patterns manquants (DESTINATAIRE, PRESCRIPTION_AUTHOR), whitelist médicaments élargie (+60), villes retirées de whitelist. Bloc B: CRH dedup chars 200-1000, CP_VILLE vrais codes postaux FR, DR_NAME capital par mot, BACTERIO header tolère ligne vide. Bloc C: DR_NAME negative lookahead multi-docteurs même ligne, entity_registry split tirets (RITZ-QUILLACQ), fix early return subparts dans _find_matching_entity, PRESCRIPTION_AUTHOR élargi (Révisé/Traité, variable.), NOTE_AUTHOR élargi (Diététicienne, Kiné, Ergo), + 8 nouveaux patterns (CONTACT_RELATION, MOD_PAR, AIDE_NAME, SIGNATURE_LINE, VALIDE_PAR, INTERNE_SIGNATURE, FOIS_NAME, MALADIE_NAME), adresses inline +ALLEE/IMP, text_cleaner préserve abréviations médicales. Validé sur 6 cas (21, 11, 104, 160, 50, 200). 70 tests OK. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
161 lines
5.9 KiB
Python
161 lines
5.9 KiB
Python
"""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]
|