Compare commits

...

26 Commits

Author SHA1 Message Date
dom
0bfc1a9d6e fix: corriger 3 tests fusion alignés sur la logique Trackare > CRH
Les tests supposaient que le CRH gagnait à spécificité égale — en réalité
le bonus Trackare l'emporte (codage DIM établissement prioritaire).
Aussi : dp_selection synthétique créée par fusion quand aucun source n'existe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 13:09:59 +01:00
dom
1da45b7c8a fix: résoudre doublon endpoint /health après merge des branches
Garde la version complète (Ollama check + timestamp) et utilise APP_VERSION.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:50:03 +01:00
dom
bd5f479832 Merge branch 'feat/test-coverage' 2026-03-08 12:44:27 +01:00
dom
234c19f6fe Merge branch 'feat/versioning-config' 2026-03-08 12:44:25 +01:00
dom
f0b0adca02 Merge branch 'fix/cleanup-repo'
# Conflicts:
#	t2a-viewer.service
2026-03-08 12:44:22 +01:00
dom
0c38bc261b chore: sauvegarde état courant avant merge des branches teammates
Modifications en cours : pipeline médical (cim10_extractor, dp_finalizer,
dp_selector, fusion, rag_search), viewer (helpers, detail.html),
cache ollama et référentiels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:36:54 +01:00
dom
aed5c87bc3 feat: endpoint /health avec tests (8 tests)
Ajoute GET /health retournant un JSON avec :
- status: "ok"
- version: "2.1.0"
- ollama: true/false (connectivité testée avec timeout 2s)
- timestamp: ISO 8601 UTC

Tests couvrent : format JSON, champs requis, Ollama joignable/injoignable,
format timestamp ISO, type booléen du champ ollama.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:01:47 +01:00
dom
dcee7c960c feat: tests étendus pour src/medical/dp_finalizer.py (34 tests)
Couvre les cas non couverts dans test_dp_finalizer.py :
- R6 Z-code non whitelisté remplacé par DAS
- Fonctions internes (_family3, _code_in_candidates, _has_strong_evidence)
- _apply_r5 en appel direct (Z whitelisté, R-code avec candidat non-R)
- Détection source Trackare par document_type/reason/evidence
- Cas limites (code None, candidats vides, pas de dp_selection)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 12:01:04 +01:00
dom
caaa6deb14 feat: tests étendus pour src/quality/decision_engine.py (53 tests)
Couvre les règles non couvertes dans le fichier existant :
- Fonctions internes (_norm, _first_float, _parse_normal_range, etc.)
- Nettoyage hiérarchique (.9 vs code spécifique)
- D50 sans preuve martiale → downgrade D64.9
- D69.6 thrombopénie vs plaquettes normales → RULED_OUT
- decision_summaries pour toutes les actions (DOWNGRADE, REMOVE, RULED_OUT, NEED_INFO)
- Détection analytes (sodium, potassium)
- _age_band et cas limites de scoring

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:59:57 +01:00
dom
5ba3903569 feat: tests pour src/viewer/helpers.py (77 tests)
Couvre les filtres Jinja2 (confidence_badge, severity_badge, cma_level_badge,
decision_badge, human_where, format_doc_name, etc.), les fonctions de
statistiques (compute_group_stats, compute_dashboard_stats, compute_dim_synthesis),
et les utilitaires (_date_to_iso, _sort_qc_alerts, _compute_jours_restants).
Utilise pytest.mark.parametrize pour les cas multiples.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:58:28 +01:00
dom
768bb94193 feat: validation config au démarrage
- Ajoute validate_config() dans src/config.py :
  * Vérifie accessibilité Ollama (warning si injoignable)
  * Warning si ANTHROPIC_API_KEY absente (fallback cloud indispo)
  * Warning si modèles CPAM et validation identiques
  * Vérifie qu'au moins un modèle LLM est configuré
- Appel automatique au démarrage du viewer Flask
- Ne fait jamais crasher l'app (warnings uniquement)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:49:12 +01:00
dom
5b58886ebf feat: versioning sémantique (single source of truth)
- Crée src/__version__.py comme source unique de version (2.1.0)
- pyproject.toml utilise dynamic version via setuptools attr
- Affiche la version dans le footer de la sidebar (base.html)
- Ajoute endpoint /health avec version et status

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:47:44 +01:00
dom
828356eff1 fix: corriger le chemin gunicorn dans t2a-viewer.service
Le chemin ExecStart pointait vers /home/dom/ai/t2a/.venv/ (ancien projet)
au lieu de /home/dom/ai/t2a_v2/.venv/ (projet actuel).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:38:37 +01:00
dom
8f43759ba4 chore: compléter .gitignore et dé-tracker output/ (1603 fichiers)
Ajouts au .gitignore :
- output/ : données patient anonymisées et résultats pipeline (ne doivent pas être versionnées)
- unsloth_compiled_cache/ : cache de compilation Unsloth
- *.rttm : fichiers de diarisation audio
- /benchmark_*.py, /bench_pipeline.py : scripts de benchmark ponctuels (racine)
- training/ : artefacts d'entraînement

Suppression du tracking de output/ via git rm --cached (1603 fichiers, ~1.2M lignes).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:38:23 +01:00
dom
ae02c81572 chore: suppression fichiers parasites non liés au projet
- unsloth_compiled_cache/ : cache de compilation Unsloth (fine-tuning LLM, non lié)
- sans titre_diarization.rttm : fichier RTTM vide (transcription audio)
- sans titre_diarized.txt : transcription audio 241 Ko (medical_ai_scribe)
- sans titre_summary_v2.md : résumé audio 11 Ko (medical_ai_scribe)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 11:37:50 +01:00
dom
214a5d1914 fix: qualité codage — anti-hallucination LLM + négation regex + veto calibration
- Prompt DAS_EXTRACTION : ajout consignes anti-hallucination (zero invention,
  pas d'inférence de comorbidités, exiger citation exacte du texte)
- Prompt CODING_CIM10 : ajout consignes conditionnel et négation
- diagnostic_extraction.py : détection de négation avant les patterns regex DAS
  (bloque "pas d'embolie", "absence de sepsis", "sans signe d'IRC", etc.)
- veto_engine.py : VETO-03 conditionnel cherche maintenant PRÈS du concept
  (40 chars), "si" isolé ne déclenche plus de faux positif, ajout cues
  (possible, risque de, aspect de, à confirmer, à rechercher)
- veto_engine.py : négation enrichie (ne retrouve pas, sans signe/argument,
  écarté, infirmé, pas mis en évidence)

Batch analysis: VETO-02 63% from LLM hallucinations, VETO-03 63% false positives

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:59:02 +01:00
dom
a371626f40 feat: dictionnaire de codage + détection anomalies statistiques
- Script build_coding_dict.py génère le dictionnaire depuis le batch (240 dossiers)
- coding_dictionary.json : co-occurrences DP→DAS, fréquences, associations bio
- anomaly_stats.py : 8 checks (DP/DAS rare, DAS manquant, bio-DAS, âge atypique)
- Intégré dans le pipeline cim10_extractor post-DIM-senior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:48:36 +01:00
dom
13fe9fa666 chore: mise à jour output pipeline (anonymized + structured)
Résultats de re-traitement pipeline v2 sur 261 dossiers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:14:42 +01:00
dom
c73515ac89 chore: mise à jour index FAISS (+15 référentiels ATIH) et cache ollama
- Index FAISS ref/proc enrichis avec 15 nouveaux documents ATIH/DGOS
- Métadonnées FAISS refactorisées (metadata_ref, metadata_proc séparés)
- Référentiels utilisateur ajoutés (5 docs)
- Nettoyage cache ollama (suppression backup gemma3)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:14:32 +01:00
dom
4b6e3cf6d5 feat: optimisations pipeline médical (bio_normals, GHM, DP selector, CIM-10)
- bio_normals: table de normes biologiques étendue (+200 analytes)
- bio_extraction: amélioration parsing valeurs biologiques
- cim10_extractor: règles supplémentaires extraction codes
- dp_selector: affinement sélection diagnostic principal
- ghm: estimation sévérité GHM enrichie
- validation_pipeline: correctifs mineurs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 23:14:13 +01:00
dom
63f61f196b feat: 8 optimisations vitesse + qualité pipeline CIM-10
1. Parallélisation intra-dossier (RAG + DP selector en parallèle)
2. Cache embeddings FAISS (_embed_cached avec LRU)
3. Lazy loading edsnlp (déjà singleton, vérifié)
4. Prompt DP amélioré avec règles PMSI/ATIH
5. Validation croisée Bio↔DAS (cohérence biologie/diagnostics)
6. Resélection DP après vetos/exclusions (reselect_dp_after_vetos)
7. Pré-filtrage R-codes (déjà implémenté dans exclusion_rules)
8. Cache embeddings texte (intégré dans rag_search)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:18:07 +01:00
dom
e6bd7406a4 chore: nettoyage YAML base.yaml + corrections templates viewer
- base.yaml: suppression commentaires verbose, normalisation quotes YAML
- Templates: corrections mineures cpam.html, detail.html, dim.html, index.html
- admin_rules.html: ajustements interface admin règles
- test_referentiels.py: mise à jour imports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:07:00 +01:00
dom
79c447688c fix: ajouter liens navigation vers interface admin regles
- Lien sidebar base.html : "Regles metier" sous "Referentiels"
- Lien croise admin_referentiels.html → regles
- Boutons dashboard : "Gerer les referentiels" + "Regles metier"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:42:42 +01:00
dom
1e837c2758 feat: interface admin regles, refactoring viewer, README, pyproject.toml
- Nouveau module rules_manager.py : CRUD YAML pour les regles metier
- Nouveau blueprint bp_rules.py + template admin_rules.html :
  interface web pour activer/desactiver/ajouter/supprimer des regles
- Extraction helpers.py depuis app.py (filtres Jinja2, statistiques,
  scan dossiers, status systeme) — app.py passe de 1585 a 482 lignes
- Suppression backward-compat re-exports dans cim10_extractor et
  cpam_response (imports corriges dans les tests)
- README.md : architecture, modules, installation, utilisation
- pyproject.toml : dependencies completes, config ruff, pytest, coverage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 19:11:27 +01:00
dom
2478928798 chore: suppression scripts obsolètes, anciens benchmarks et fichiers de dev
- Suppression scripts racine : analyze_pdfs.py, rebuild_index.py,
  compare_cpam_models.py, test_cpam_quality.py, test_quality_tier_live.py
- Suppression docs obsolètes : rapport_analyse_pdfs.md,
  ANALYSE_COHERENCE_ET_AMELIORATIONS.md, patch_0+1.md
- Suppression outils CPAM legacy : extract_t2a_llm.py, parse_decision_ucr.py
- Suppression backups CPAM : *.xlsx_old
- Suppression hors-git : 19 archives .zip, cache gemma3.bak

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:49:26 +01:00
dom
4e2b4bd946 refactor: réorganisation référentiels, nouveaux modules extraction, nettoyage code obsolète
- Réorganisation data/referentiels/ : pdfs/, dicts/, user/ (structure unifiée)
- Fix badges "Source absente" sur page admin référentiels
- Ré-indexation COCOA 2025 (555 → 1451 chunks, couverture 94%)
- Fix VRAM OOM : embeddings forcés CPU via T2A_EMBED_CPU
- Nouveaux modules : document_router, docx_extractor, image_extractor, ocr_engine
- Module complétude (quality/completude.py + config YAML)
- Template DIM (synthèse dimensionnelle)
- Gunicorn config + systemd service t2a-viewer
- Suppression t2a_install_rag_cleanup/ (copie obsolète)
- Suppression scripts/ et scripts_t2a_v2/ (anciens benchmarks)
- Suppression 81 fichiers _doc.txt de test
- Cache Ollama : TTL configurable, corrections loader YAML
- Dashboard : améliorations templates (base, index, detail, cpam, validation)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:48:10 +01:00
1670 changed files with 72470 additions and 1604267 deletions

View File

@@ -1,32 +0,0 @@
# Règles métier T2A — Connaissances critiques
## 1. Index alphabétique CIM-10
- Ne pas se contenter de vectoriser les codes (liste analytique)
- Vectoriser les **index alphabétiques** : un médecin cherche "Gastrite", pas "K29.7"
- Le lien langage naturel → code est bien plus riche dans l'index alphabétique
## 2. Validité temporelle des codes CCAM
- Chaque code CCAM a une date de début et de fin de validité
- Si un acte est hors période de validité (supprimé ou remplacé dans une version), le **groupage plantera**
- Le RAG doit toujours vérifier les dates de validité des codes dans les tables de référence
- Version actuelle : CCAM V4 2025
## 3. Diagnostics d'exclusion (piège IA classique)
- Si le patient a un symptôme (R10.4 "Douleur abdominale") ET un diagnostic précis (K35.8 "Appendicite"),
le symptôme est **exclu** au profit du diagnostic précis
- Règle : les codes **Chapitres I à XIV** de la CIM-10 priment sur les codes **Chapitre XVIII** (symptômes)
- Le reranker doit implémenter cette priorisation
## 4. Hiérarchie des actes CCAM (non-cumul)
- La CCAM n'est pas que du texte, c'est de la **combinatoire**
- Règles de non-cumul : deux actes anatomiquement incompatibles ou inclus l'un dans l'autre → **alerte**
- Doit être vérifié selon le référentiel CCAM
## 5. Sévérité CMA/CMS (nerf de la guerre GHM)
- CMA = Complications ou Morbidités Associées
- CMS = Complications ou Morbidités Associées Sévères
- La détection des CMA/CMS détermine le passage du **niveau 1 au niveau 4 du GHM**
- Différence de valorisation financière énorme
- Le NLP doit chercher spécifiquement les **marqueurs de sévérité**
- Ex: "Insuffisance rénale **aiguë**" vs "**chronique**" → codes et niveaux différents
- Ex: "Dénutrition **sévère**" vs "modérée"

16
.gitignore vendored
View File

@@ -74,3 +74,19 @@ htmlcov/
# === Backups ===
*_backup_*
backups/
# === Output (données patient anonymisées, résultats pipeline) ===
output/
# === Caches externes ===
unsloth_compiled_cache/
# === Fichiers audio/diarisation ===
*.rttm
# === Scripts de benchmark ponctuels (racine seulement) ===
/benchmark_*.py
/bench_pipeline.py
# === Training artifacts ===
training/

View File

@@ -1,891 +0,0 @@
# Analyse Complète et Recommandations d'Amélioration
## T2A v2 - Système Expert de Codage Médical
**Date**: 2026-02-19
**Version analysée**: rules_bio_v2 + lab_sanity_v1 + ruled_out_v1
**Analyse**: Codebase complète (45 fichiers Python, ~11 000 lignes)
---
## 0. PÉRIMÈTRE DE L'ANALYSE
### Architecture Complète Analysée
```
src/
├── anonymization/ # 4 fichiers, ~900 LOC - Anonymisation PII
├── extraction/ # 6 fichiers, ~900 LOC - Extraction PDF/parsing
├── medical/ # 13 fichiers, ~5500 LOC - Cœur métier
├── quality/ # 2 fichiers, ~1000 LOC - Vetos + décisions
├── control/ # 2 fichiers, ~1200 LOC - Contrôle CPAM
├── viewer/ # 4 fichiers, ~1500 LOC - Interface web
├── export/ # 1 fichier, ~200 LOC - Export RUM
├── main.py # 600 LOC - Orchestration
└── config.py # 500 LOC - Modèles de données
Total: 45 fichiers, ~11 000 LOC
Tests: 30 fichiers, ~6000 LOC
```
### Modules Critiques Identifiés
1. **medical/cim10_extractor.py** (1352 LOC) - Extraction diagnostics/actes
2. **medical/rag_search.py** (849 LOC) - Enrichissement RAG/LLM
3. **control/cpam_response.py** (1046 LOC) - Génération contre-arguments CPAM
4. **viewer/app.py** (872 LOC) - Interface web Flask
5. **quality/decision_engine.py** (593 LOC) - Moteur de décisions
6. **quality/veto_engine.py** (402 LOC) - Règles de qualité
---
## 1. ÉTAT ACTUEL DU SYSTÈME
### ✅ Points Forts
#### Architecture Modulaire
- **Séparation claire** : extraction → anonymisation → analyse → qualité → fusion
- **Configuration YAML** : 3 fichiers distincts et cohérents
- `reference_ranges.yaml` : normes biologiques médicales
- `bio_rules.yaml` : règles de validation diagnostique
- `lab_value_sanity.yaml` : garde-fous d'extraction
- **Traçabilité complète** : chaque décision est documentée avec preuves
#### Système de Qualité Robuste
- **16+ règles VETO** implémentées (VETO-02, 03, 06, 07, 09, 12, 15, 16, 17)
- **3 niveaux de sévérité** : HARD (bloquant) / MEDIUM (info requise) / LOW (alerte)
- **Verdicts clairs** : PASS / NEED_INFO / FAIL
- **Métriques détaillées** : actifs/total/écartés/ruled_out/removed/no_code
#### Validation Biologique Intelligente
- **Détection ruled_out** : diagnostics contredits par la biologie (ex: thrombopénie avec PLT=270)
- **Sanity checks** : identification des valeurs aberrantes (ex: K=8 → suspect)
- **Safe zones** : seuils conservateurs pour âge inconnu
- **VETO-17** : alerte si diagnostic d'ionogramme sans valeur extraite
#### Extraction PDF Performante
- **pdfplumber 0.11.9** : extraction texte natif (pas d'OCR)
- **Rapide** : ~30-50s par dossier avec cache
- **Filtrage artefacts** : détection patterns OCR Trackare
---
## 2. ANALYSE DE COHÉRENCE
### ✅ Cohérence Globale : EXCELLENTE
#### Architecture Complète
```
Pipeline Principal (main.py):
1. Extraction PDF → document_classifier → split_documents
2. Parsing → crh_parser / trackare_parser
3. Anonymisation → 3 phases (regex → NER → sweep)
4. Analyse médicale → edsnlp + cim10_extractor
5. Enrichissement RAG → rag_search (optionnel)
6. Qualité → veto_engine + decision_engine
7. Fusion multi-PDF → merge_dossiers
8. Export → JSON + RUM + viewer web
Modules Transverses:
- cim10_dict / ccam_dict : Référentiels
- rag_index : FAISS vectoriel (22k+ vecteurs)
- ollama_cache : Cache LLM
- severity : Évaluation CMA/CMS
- ghm : Estimation GHM
- cpam_response : Contre-arguments CPAM
```
#### Points Forts Supplémentaires Identifiés
**1. Système de Validation Multi-Niveaux**
- **Tests unitaires** : 30 fichiers, ~6000 LOC, couverture ~80%
- **Interface de validation** : `viewer/validation.py` avec annotations manuelles
- **Métriques de performance** : Benchmarking multi-modèles
- **Contrôle CPAM** : Parsing Excel + génération réponses structurées
**2. Gestion Avancée des Référentiels**
- **Référentiels utilisateur** : Upload/indexation dynamique (viewer/referentiels.py)
- **Chunking intelligent** : TXT, CSV, PDF avec stratégies adaptées
- **Mise à jour à chaud** : Rebuild index sans redémarrage
**3. Extraction Biologique Sophistiquée**
```python
# cim10_extractor.py lignes 800-900
- Détection normes document : "[N: 135-145]"
- Parsing multi-formats : "4,5" / "4.5" / "4 mmol/L"
- Sanity checks : lab_value_sanity.yaml
- Interprétation clinique : clinical_context.py
```
**4. Système de Fusion Intelligent**
```python
# fusion.py
- Déduplication sémantique (apply_semantic_dedup)
- Hiérarchie codes parent/enfant
- Préférence codes enrichis RAG
- Gestion conflits DP/DAS
```
**5. Anonymisation Robuste**
```python
# anonymization/
- Phase 1 : Regex (IPP, RPPS, dates, téléphones)
- Phase 2 : NER CamemBERT (noms, prénoms)
- Phase 3 : Sweep patterns résiduels
- Whitelist : Établissements médicaux préservés
```
**6. Interface Web Complète**
```python
# viewer/app.py
- Dashboard : Stats verdicts, top VETOs
- Détail dossier : Preuves cliniques, sources RAG
- PDF redacté : Annotations + highlights
- Admin référentiels : Upload/delete/rebuild
- Validation : Annotations manuelles + métriques
```
---
## 3. LACUNES IDENTIFIÉES (REVUE COMPLÈTE)
### 🔴 Critiques (Impact Fort)
#### 3.1 Règles Biologiques Incomplètes ✅ CONFIRMÉ
**Fichiers concernés** :
- `src/quality/decision_engine.py` (lignes 100-400)
- `config/bio_rules.yaml` (3 règles seulement)
**Règles actuelles** :
```python
# decision_engine.py lignes 380-450
- hyponatremia (E87.1) vs sodium
- hyperkalemia (E87.5) vs potassium
- hypokalemia (E87.6) vs potassium
```
**Diagnostics manquants** (confirmés par analyse codebase) :
- **Anémie** (D50-D64) : Code présent dans `_anemia_bio()` mais incomplet
- **Insuffisance rénale** (N17-N19) : Détection partielle dans veto_engine.py ligne 355
- **Hypoglycémie/Hyperglycémie** : Aucune règle
- **Troubles hépatiques** (K70-K77) : Aucune validation ASAT/ALAT
- **Hypercalcémie/Hypocalcémie** : Aucune règle
- **Troubles thyroïdiens** : Aucune règle
**Impact** : ~60% des diagnostics biologiques non validés
#### 3.2 Extraction Ionogrammes Partielle ✅ CONFIRMÉ
**Fichier** : `src/medical/cim10_extractor.py` lignes 800-950
**Tests extraits actuellement** :
```python
# _extract_biologie() ligne 850
BIO_PATTERNS = {
"CRP", "ASAT", "ALAT", "Créatinine", "Hémoglobine",
"Leucocytes", "Plaquettes", "Sodium", "Potassium"
}
```
**Tests manquants** :
- Chlore, Calcium, Magnésium, Phosphore
- Glucose, HbA1c, Urée
- TSH, T3, T4, Bilirubine totale/conjuguée
- GGT, PAL (partiellement présents dans lab_value_sanity.yaml mais pas extraits)
**Impact** : Impossible de valider E87.2/E87.3 (acidose/alcalose), E83.x (calcium/magnésium)
#### 3.3 Pas de Validation Temporelle ✅ NOUVEAU
**Fichiers analysés** :
- `src/config.py` (Sejour model)
- `src/quality/veto_engine.py` (aucune règle temporelle)
**Champs disponibles non exploités** :
```python
# config.py Sejour
date_entree: str | None
date_sortie: str | None
duree_sejour: int | None
```
**Exemples manquants** :
- DAS "aigu" avec séjour > 30 jours
- Durée incohérente avec pathologie (AVC avec 1 jour)
- Dates actes hors période séjour
**Impact** : Risque de sur-codage chronique/aigu
#### 3.4 Pas de Validation Âge/Sexe ✅ NOUVEAU
**Fichiers analysés** :
- `src/extraction/crh_parser.py` / `trackare_parser.py` (extraction âge/sexe)
- `src/quality/veto_engine.py` (aucune règle démographique)
**Champs disponibles non exploités** :
```python
# config.py Patient
sexe: str | None # "M" / "F"
date_naissance: str | None
age: int | None
```
**Impact** : Erreurs grossières non détectées (grossesse chez homme, etc.)
#### 3.5 VETO-09 Trop Basique ✅ CONFIRMÉ
**Fichier** : `src/quality/veto_engine.py` lignes 330-360
**Code actuel** :
```python
# Seulement 2 validations :
1. Plaquettes vs D69 (thrombopénie)
2. Créatinine vs N17/N18/N19 (insuffisance rénale) - LOW severity seulement
```
**Manque** :
- Hémoglobine vs anémie (D50-D64)
- Leucocytes vs leucopénie/leucocytose (D70/D72)
- Glucose vs diabète (E10-E14)
- Transaminases vs hépatite (K70-K77)
- CRP vs inflammation (R50)
**Impact** : 80% des contradictions biologiques non détectées
#### 3.6 Pas de Règles de Cohérence Inter-Diagnostics ✅ NOUVEAU
**Fichiers analysés** :
- `src/medical/fusion.py` (déduplication sémantique partielle)
- `src/medical/exclusion_rules.py` (exclusions symptômes/précis uniquement)
**Règles existantes** :
```python
# exclusion_rules.py
- Symptômes exclus si diagnostic précis présent
- Ex: R10 (douleur abdominale) exclu si K35 (appendicite)
```
**Manque** :
- Diagnostics mutuellement exclusifs (E10 + E11)
- Incompatibilités cliniques (obésité + dénutrition)
- Hiérarchies codes (K81.0 exclut K81.9)
**Impact** : Incohérences cliniques non signalées
#### 3.7 Pas de Validation Actes/Diagnostics ✅ NOUVEAU
**Fichiers analysés** :
- `src/medical/cim10_extractor.py` (extraction actes CCAM)
- `src/medical/ccam_noncumul.py` (non-cumul uniquement)
**Règles existantes** :
```python
# ccam_noncumul.py
- Détection actes non-cumulables même jour
- Ex: HFCA001 + HFCA002 (cholécystectomie)
```
**Manque** :
- Acte chirurgical nécessite diagnostic justificatif
- Diagnostic nécessite acte (si séjour chirurgical)
**Impact** : Actes non justifiés non détectés
### 🟠 Importantes (Impact Moyen)
#### 3.8 Système de Cache LLM Basique ✅ NOUVEAU
**Fichier** : `src/medical/ollama_cache.py` (85 LOC)
**Implémentation actuelle** :
```python
# Cache JSON simple sur disque
- Clé : hash(model + prompt + params)
- Pas de TTL
- Pas de limite taille
- Pas de stratégie éviction
```
**Manque** :
- Cache distribué (Redis)
- TTL configurable
- Limite mémoire/disque
- Métriques hit rate
**Impact** : Performance dégradée sur gros volumes
#### 3.9 Pas de Scoring de Confiance Global ✅ CONFIRMÉ
**Fichier** : `src/quality/veto_engine.py` lignes 390-402
**Score actuel** :
```python
# Calcul simpliste
score = 100
for issue in issues:
if severity == "HARD": score -= 30
elif severity == "MEDIUM": score -= 10
else: score -= 3
```
**Manque** :
- Pondération par type VETO
- Score de complétude extraction
- Indicateur fiabilité RAG
- Taux de confiance LLM agrégé
**Impact** : Difficile de prioriser dossiers à revoir
#### 3.10 Interface Web Sans Authentification ✅ NOUVEAU
**Fichier** : `src/viewer/app.py` (872 LOC)
**Sécurité actuelle** :
```python
# Aucune authentification
# Aucune autorisation
# Pas de HTTPS forcé
# Pas de CSRF protection
```
**Impact** : Risque sécurité en production
### 🟡 Mineures (Impact Faible)
#### 3.11 Pas de Suggestions Automatiques ✅ CONFIRMÉ
**Fichiers analysés** : Aucun module de suggestions
**Manque** :
- Suggestions corrections automatiques
- Codes alternatifs proposés
- DAS manquants évidents
#### 3.12 Logs Non Structurés ✅ NOUVEAU
**Fichier** : `src/main.py` (utilise logging standard)
**Manque** :
- Logs JSON structurés
- Corrélation ID par dossier
- Métriques Prometheus
- Tracing distribué
---
## 4. RECOMMANDATIONS PRIORITAIRES
### 🎯 Phase 1 : Règles Biologiques Complètes (Priorité HAUTE)
#### 4.1 Étendre `bio_rules.yaml`
```yaml
rules:
# Ionogrammes (existant)
hyponatremia: { codes: ["E87.1"], analyte: sodium }
hyperkalemia: { codes: ["E87.5"], analyte: potassium }
hypokalemia: { codes: ["E87.6"], analyte: potassium }
# NOUVEAU : Anémies
anemia_iron_deficiency:
codes: ["D50.0", "D50.1", "D50.8", "D50.9"]
analyte: hemoglobin
threshold_type: low
anemia_other:
codes: ["D51", "D52", "D53", "D55-D64"]
analyte: hemoglobin
threshold_type: low
# NOUVEAU : Insuffisance rénale
acute_kidney_injury:
codes: ["N17.0", "N17.1", "N17.2", "N17.8", "N17.9"]
analyte: creatinine
threshold_type: high
chronic_kidney_disease:
codes: ["N18.1", "N18.2", "N18.3", "N18.4", "N18.5"]
analyte: creatinine
threshold_type: high
requires_gfr: true # Calcul DFG nécessaire
# NOUVEAU : Diabète
hyperglycemia:
codes: ["E16.1", "R73.9"]
analyte: glucose
threshold_type: high
hypoglycemia:
codes: ["E16.2"]
analyte: glucose
threshold_type: low
diabetes_uncontrolled:
codes: ["E10.1", "E11.1"] # avec complications
analyte: hba1c
threshold_type: high
threshold_value: 9.0 # > 9% = déséquilibré
# NOUVEAU : Troubles hépatiques
hepatic_cytolysis:
codes: ["K72.0", "K72.9", "K75.9"]
analytes: ["asat", "alat"] # multi-analytes
threshold_type: high
threshold_multiplier: 3 # > 3x normale
cholestasis:
codes: ["K83.1"]
analytes: ["ggt", "pal"]
threshold_type: high
# NOUVEAU : Inflammation
inflammatory_syndrome:
codes: ["R50.9"] # Fièvre sans précision
analyte: crp
threshold_type: high
threshold_value: 10 # > 10 mg/L
```
#### 4.2 Étendre Extraction Biologique
**Fichier** : `src/medical/cim10_extractor.py`
**Ajouter patterns** :
```python
BIO_PATTERNS = {
# Existant
"sodium": r"(?:sodium|na)\s*[:\s]*(\d+(?:[.,]\d+)?)",
"potassium": r"(?:potassium|kalium|k)\s*[:\s]*(\d+(?:[.,]\d+)?)",
# NOUVEAU
"chlore": r"(?:chlore|cl)\s*[:\s]*(\d+(?:[.,]\d+)?)",
"calcium": r"(?:calcium|ca)\s*[:\s]*(\d+(?:[.,]\d+)?)",
"magnesium": r"(?:magn[ée]sium|mg)\s*[:\s]*(\d+(?:[.,]\d+)?)",
"glucose": r"(?:glucose|glyc[ée]mie)\s*[:\s]*(\d+(?:[.,]\d+)?)",
"hba1c": r"(?:hba1c|h[ée]moglobine\s+glyqu[ée]e)\s*[:\s]*(\d+(?:[.,]\d+)?)",
"uree": r"(?:ur[ée]e)\s*[:\s]*(\d+(?:[.,]\d+)?)",
"tsh": r"(?:tsh)\s*[:\s]*(\d+(?:[.,]\d+)?)",
"t3": r"(?:t3)\s*[:\s]*(\d+(?:[.,]\d+)?)",
"t4": r"(?:t4)\s*[:\s]*(\d+(?:[.,]\d+)?)",
}
```
#### 4.3 Étendre `lab_value_sanity.yaml`
```yaml
tests:
# Existant : potassium, sodium, plaquettes, hemoglobine...
# NOUVEAU
chlore:
hard_min: 70
hard_max: 150
calcium:
hard_min: 1.5
hard_max: 4.0
glucose:
hard_min: 1.0
hard_max: 50.0
suspect:
single_digit_over: 8.0 # "9" souvent = "4.9"
hba1c:
hard_min: 3.0
hard_max: 20.0
tsh:
hard_min: 0.01
hard_max: 100.0
```
**Effort** : 2-3 jours
**Impact** : +60% diagnostics biologiques validés
---
### 🎯 Phase 2 : Validation Démographique (Priorité HAUTE)
#### 4.4 Créer `config/demographic_rules.yaml`
```yaml
version: 1
age_rules:
pediatric_only:
codes: ["P00-P96"] # Affections périnatales
max_age_years: 1
veto: VETO-18
severity: HARD
pregnancy_related:
codes: ["O00-O99"] # Grossesse, accouchement
min_age_years: 12
max_age_years: 55
required_sex: F
veto: VETO-19
severity: HARD
menopause:
codes: ["N95"]
min_age_years: 40
required_sex: F
veto: VETO-19
severity: MEDIUM
prostate:
codes: ["C61", "N40", "N41", "N42"]
required_sex: M
veto: VETO-19
severity: HARD
sex_rules:
male_only:
codes: ["C61", "N40-N51", "Z12.5"]
required_sex: M
veto: VETO-19
severity: HARD
female_only:
codes: ["C50-C58", "D05-D07", "N70-N98", "O00-O99", "Z12.3"]
required_sex: F
veto: VETO-19
severity: HARD
```
#### 4.5 Implémenter dans `veto_engine.py`
```python
# VETO-18 : Incohérence âge
# VETO-19 : Incohérence sexe
def _check_demographic_rules(dossier: DossierMedical, config: dict) -> list[VetoIssue]:
issues = []
patient_age = dossier.patient.age_years if dossier.patient else None
patient_sex = dossier.patient.sexe if dossier.patient else None
for das in dossier.diagnostics_associes:
code = das.cim10_suggestion
if not code:
continue
# Vérifier règles d'âge
for rule_name, rule in config.get("age_rules", {}).items():
if _code_matches_range(code, rule["codes"]):
if patient_age:
if "min_age_years" in rule and patient_age < rule["min_age_years"]:
issues.append(VetoIssue(
veto=rule["veto"],
severity=rule["severity"],
where=f"DAS {code}",
message=f"Âge {patient_age} ans < minimum {rule['min_age_years']} ans"
))
# ... max_age_years similaire
# Vérifier règles de sexe
# ... similaire
return issues
```
**Effort** : 1-2 jours
**Impact** : Détection erreurs grossières (5-10% des dossiers)
---
### 🎯 Phase 3 : Cohérence Inter-Diagnostics (Priorité MOYENNE)
#### 4.6 Créer `config/diagnostic_conflicts.yaml`
```yaml
version: 1
# Diagnostics mutuellement exclusifs
mutual_exclusions:
- group: "Diabète type"
codes: ["E10", "E11", "E13", "E14"]
max_allowed: 1
veto: VETO-20
severity: HARD
message: "Plusieurs types de diabète codés simultanément"
- group: "Insuffisance cardiaque latéralité"
codes: ["I50.1", "I50.0"] # gauche + droite
suggest: "I50.9" # globale
veto: VETO-20
severity: MEDIUM
- group: "Hypertension vs Hypotension"
codes: ["I10", "I95"]
veto: VETO-20
severity: HARD
# Diagnostics incompatibles
incompatibilities:
- code: "E66" # Obésité
incompatible_with: ["E40", "E41", "E42", "E43", "E44", "E45", "E46"] # Dénutrition
veto: VETO-21
severity: HARD
- code: "Z94.0" # Rein transplanté
incompatible_with: ["N18.5"] # IRC terminale
veto: VETO-21
severity: MEDIUM
message: "Transplantation réussie incompatible avec IRC terminale active"
# Hiérarchies (code spécifique exclut code générique)
hierarchies:
- specific: "K81.0" # Cholécystite aiguë
excludes: "K81.9" # Cholécystite SAI
veto: VETO-22
severity: LOW
action: "remove_generic"
```
**Effort** : 2-3 jours
**Impact** : +15% qualité codage
---
### 🎯 Phase 4 : Validation Actes/Diagnostics (Priorité MOYENNE)
#### 4.7 Créer `config/procedure_diagnosis_rules.yaml`
```yaml
version: 1
# Acte chirurgical nécessite diagnostic justificatif
required_diagnosis:
- procedure_pattern: "HFCA" # Cholécystectomie
required_codes: ["K80", "K81", "K82"]
veto: VETO-23
severity: HARD
message: "Cholécystectomie sans pathologie vésiculaire"
- procedure_pattern: "HHFA" # Appendicectomie
required_codes: ["K35", "K36", "K37", "K38"]
veto: VETO-23
severity: HARD
- procedure_pattern: "DZQM" # Pose stent coronaire
required_codes: ["I20", "I21", "I22", "I23", "I24", "I25"]
veto: VETO-23
severity: HARD
- procedure_pattern: "JVJF" # Dialyse
required_codes: ["N17", "N18", "N19"]
veto: VETO-23
severity: HARD
# Diagnostic nécessite acte (si séjour chirurgical)
expected_procedure:
- diagnosis: "K35.8" # Appendicite aiguë
expected_pattern: "HHFA"
if_stay_type: "chirurgical"
veto: VETO-24
severity: MEDIUM
message: "Appendicite aiguë sans appendicectomie (séjour chirurgical)"
```
**Effort** : 3-4 jours
**Impact** : +20% détection incohérences actes
---
### 🎯 Phase 5 : Scoring et Suggestions (Priorité BASSE)
#### 4.8 Score de Qualité Global
```python
def calculate_quality_score(veto_report: VetoReport) -> dict:
"""Calcule un score de qualité 0-100."""
base_score = 100
penalties = {
"HARD": 20,
"MEDIUM": 10,
"LOW": 5
}
for issue in veto_report.issues:
base_score -= penalties.get(issue.severity, 0)
return {
"score": max(0, base_score),
"grade": _score_to_grade(base_score),
"confidence": _calculate_confidence(veto_report)
}
def _score_to_grade(score: int) -> str:
if score >= 90: return "A"
if score >= 75: return "B"
if score >= 60: return "C"
if score >= 40: return "D"
return "F"
```
#### 4.9 Suggestions Automatiques
```python
def generate_suggestions(dossier: DossierMedical, veto_report: VetoReport) -> list[Suggestion]:
"""Génère des suggestions de correction."""
suggestions = []
for das in dossier.diagnostics_associes:
if das.status == "ruled_out":
suggestions.append(Suggestion(
type="remove",
target=das.cim10_suggestion,
reason=das.ruled_out_reason,
confidence="high"
))
if das.cim10_suggestion and das.cim10_suggestion.endswith(".9"):
# Code imprécis, chercher plus spécifique
specific = _find_more_specific_code(das.texte, das.cim10_suggestion)
if specific:
suggestions.append(Suggestion(
type="upgrade",
from_code=das.cim10_suggestion,
to_code=specific,
reason="Code plus spécifique disponible",
confidence="medium"
))
return suggestions
```
**Effort** : 2-3 jours
**Impact** : Amélioration UX, aide à la décision
---
## 5. ROADMAP RECOMMANDÉE
### Sprint 1 (1 semaine) - Biologie Complète
- [ ] Étendre `bio_rules.yaml` (anémie, insuffisance rénale, diabète)
- [ ] Ajouter extraction glucose, HbA1c, calcium, chlore
- [ ] Étendre `lab_value_sanity.yaml`
- [ ] Tests sur 50 dossiers
### Sprint 2 (1 semaine) - Validation Démographique
- [ ] Créer `demographic_rules.yaml`
- [ ] Implémenter VETO-18 (âge) et VETO-19 (sexe)
- [ ] Tests sur dossiers pédiatriques et obstétriques
### Sprint 3 (1 semaine) - Cohérence Inter-Diagnostics
- [ ] Créer `diagnostic_conflicts.yaml`
- [ ] Implémenter VETO-20, 21, 22
- [ ] Tests sur dossiers complexes (polypathologie)
### Sprint 4 (1 semaine) - Validation Actes
- [ ] Créer `procedure_diagnosis_rules.yaml`
- [ ] Implémenter VETO-23, 24
- [ ] Tests sur dossiers chirurgicaux
### Sprint 5 (3 jours) - Scoring et Suggestions
- [ ] Implémenter score qualité global
- [ ] Système de suggestions automatiques
- [ ] Dashboard de métriques
---
## 6. MÉTRIQUES DE SUCCÈS
### Objectifs Quantitatifs
- **Taux de détection erreurs** : 60% → 90%
- **Faux positifs** : < 5%
- **Couverture règles biologiques** : 40% → 95%
- **Temps de traitement** : < 60s par dossier
- **Taux PASS** : 50% → 70% (avec règles strictes)
### Objectifs Qualitatifs
- Zéro erreur grossière non détectée (sexe, âge)
- Cohérence 100% diagnostics/actes chirurgicaux
- Traçabilité complète de chaque décision
- Documentation exhaustive des règles
---
## 7. CONCLUSION
### État Actuel : 8.5/10 (Révisé après analyse complète)
Le système est **remarquablement complet et professionnel**, avec :
- **Architecture solide** : 11 000 LOC bien structurées
- **Tests exhaustifs** : 6000 LOC de tests, couverture ~80%
- **Interface web complète** : Dashboard, validation, admin
- **Contrôle CPAM** : Génération contre-arguments automatique
- **Anonymisation robuste** : 3 phases (regex + NER + sweep)
- **RAG avancé** : 22k+ vecteurs, chunking intelligent
Les lacunes identifiées sont **des extensions naturelles** d'un système déjà très mature.
### Potentiel : 9.8/10 (Révisé)
Avec les améliorations proposées, le système peut devenir **la référence absolue** pour le codage PMSI, dépassant largement les solutions commerciales.
### Forces Uniques Confirmées
1. **Open source et auditable** : Traçabilité complète
2. **Configuration YAML** : Lisible par non-développeurs
3. **Interface de validation** : Annotations manuelles + métriques
4. **Contrôle CPAM intégré** : Unique sur le marché
5. **Extensibilité illimitée** : Architecture modulaire
6. **Tests exhaustifs** : 30 fichiers de tests
7. **Référentiels dynamiques** : Upload/indexation à chaud
### Priorités Immédiates (Inchangées)
1. **Règles biologiques complètes** (impact maximal)
2. **Validation démographique** (erreurs grossières)
3. **Cohérence inter-diagnostics** (qualité globale)
4. **Sécurité interface web** (production-ready)
### Recommandations Supplémentaires
#### Production-Ready Checklist
- [ ] Authentification/autorisation (OAuth2 + RBAC)
- [ ] HTTPS forcé + CSRF protection
- [ ] Logs structurés JSON + corrélation ID
- [ ] Métriques Prometheus + alerting
- [ ] Cache distribué Redis
- [ ] Rate limiting API
- [ ] Backup automatique référentiels
- [ ] Documentation API (OpenAPI/Swagger)
#### Optimisations Performance
- [ ] Batch processing parallèle (multiprocessing)
- [ ] Cache RAG en mémoire (LRU)
- [ ] Lazy loading modèles NER
- [ ] Compression JSON outputs
- [ ] Index FAISS optimisé (IVF)
#### Qualité Code
- [ ] Type hints complets (mypy strict)
- [ ] Linting (ruff/black)
- [ ] Pre-commit hooks
- [ ] CI/CD pipeline (GitHub Actions)
- [ ] Code coverage > 90%
---
## 8. MÉTRIQUES DE SUCCÈS (Révisées)
### Objectifs Quantitatifs
- **Taux de détection erreurs** : 70% → 95% (actuellement meilleur que prévu)
- **Faux positifs** : < 3% (actuellement ~5%)
- **Couverture règles biologiques** : 40% → 98%
- **Temps de traitement** : < 45s par dossier (actuellement ~50s)
- **Taux PASS** : 50% → 75% (avec règles strictes)
- **Uptime production** : > 99.5%
- **Temps réponse API** : < 2s (p95)
### Objectifs Qualitatifs
- Zéro erreur grossière non détectée (sexe, âge)
- Cohérence 100% diagnostics/actes chirurgicaux
- Traçabilité complète de chaque décision
- Documentation exhaustive des règles
- Interface utilisateur intuitive
- Support multi-établissements
---
## 9. COMPARAISON SOLUTIONS COMMERCIALES
### T2A v2 vs Solutions du Marché
| Critère | T2A v2 | Solutions Commerciales |
|---------|--------|------------------------|
| **Prix** | Open source | 50k-200k€/an |
| **Traçabilité** | Complète (JSON) | Boîte noire |
| **Extensibilité** | Illimitée (YAML) | Limitée |
| **Contrôle CPAM** | Intégré | Absent |
| **Validation manuelle** | Interface dédiée | Externe |
| **RAG/LLM** | Configurable | Propriétaire |
| **Tests** | 6000 LOC | Non accessible |
| **Anonymisation** | 3 phases robustes | Variable |
| **Export RUM** | Natif | Souvent payant |
| **Référentiels** | Upload dynamique | Mise à jour éditeur |
**Verdict** : T2A v2 est déjà **supérieur** sur 8/10 critères.
---
**Auteur** : Kiro AI Assistant
**Contact** : AWS Support
**Dernière mise à jour** : 2026-02-19 17:10

121
README.md Normal file
View File

@@ -0,0 +1,121 @@
# T2A — Pipeline de codage PMSI automatise
Pipeline d'extraction et de codage CIM-10/CCAM pour le PMSI hospitalier (MCO).
Transforme les comptes rendus d'hospitalisation (CRH) et fiches Trackare en dossiers structures, codes et valorises.
## Architecture
```
input/ PDFs bruts (CRH, Trackare, anapath, bacterio)
|
v
[Extraction] pdfplumber / OCR / DOCX / images
|
v
[Anonymisation] CamemBERT NER + regex (PHI -> pseudonymes)
|
v
[Codage CIM-10] LLM local (Ollama) + RAG FAISS + regles ATIH
| diagnostic_extraction -> validation_pipeline
v
[Arbitrage DP] dp_selector (LLM) -> dp_finalizer (deterministe)
| Trackare vs CRH-only, traçabilite audit
v
[Qualite] veto_engine (contestabilite) + decision_engine
| completude (checklist documents) + severity (CMA)
v
[CPAM] cpam_parser + cpam_response (contre-argumentation LLM)
| guardian deterministe + validation adversariale
v
output/ JSON structures, rapports, export RUM
|
v
[Viewer Flask] Dashboard, detail dossier, synthese DIM, CPAM, validation
```
## Modules principaux
| Module | Role |
|--------|------|
| `src/extraction/` | Parsers PDF, DOCX, images, OCR, classification documents |
| `src/anonymization/` | Anonymisation NER + regex, registre d'entites |
| `src/medical/` | CIM-10, CCAM, biologie, RAG FAISS, LLM Ollama, fusion multi-documents |
| `src/quality/` | Moteur de vetos deterministe, decisions, completude, routage regles |
| `src/control/` | Controles CPAM, contre-argumentation, validation adversariale |
| `src/viewer/` | Application Flask (dashboard, detail, DIM, admin, regles) |
| `config/` | 12 fichiers YAML de regles editables via l'interface web |
## Moteur de regles
Le pipeline utilise un **moteur de regles 100% deterministe** (pas de LLM) pour :
- **Vetos** : bloquer les codes sans preuve, negatifs, doublons, contradictions bio
- **Decisions** : downgrade, ecartement, promotion DP
- **Conflits** : exclusions mutuelles CIM-10, incompatibilites
- **Bio** : contradiction labo vs diagnostic code
- **Completude** : checklist documents manquants
Toutes les regles sont dans `config/*.yaml` et editables via `/admin/rules`.
## RAG (Retrieval-Augmented Generation)
Index FAISS avec ~23 000 vecteurs issus de :
- CIM-10 FR 2026, Guide Methodologique MCO 2026, CCAM V4
- 30 referentiels supplementaires (COCOA 2025, fascicules ATIH, etc.)
- Embeddings : `sentence-camembert-large` (francais medical)
Separation en 3 index : `ref` (referentiels), `proc` (procedures), `bio` (biologie).
## Installation
```bash
# Prerequis : Python 3.11+, Ollama avec gemma3:27b
git clone <repo> && cd t2a_v2
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
# Variables d'environnement (.env)
OLLAMA_URL=http://localhost:11434
T2A_MODEL_CODING=gemma3:27b
T2A_MODEL_CPAM=mistral-small3.2:24b
# ANTHROPIC_API_KEY=sk-... (optionnel, fallback cloud)
```
## Utilisation
```bash
# Pipeline CLI : traiter des PDFs
python -m src.main input/dossier/
# Reconstruire l'index RAG
python -m src.main --rebuild-index
# Viewer web (developpement)
python -m src.viewer
# Viewer web (production)
gunicorn -c gunicorn.conf.py 'src.viewer:create_app()'
```
## Tests
```bash
pytest # 239+ tests, ~10s
pytest -k test_viewer # Tests viewer uniquement
pytest -k test_cpam # Tests CPAM
```
## Structure des donnees
Chaque dossier produit un JSON structure (`DossierMedical` Pydantic) contenant :
- `diagnostic_principal` : code CIM-10, confiance, justification, source
- `diagnostics_associes` : DAS avec decisions (KEEP/DOWNGRADE/REMOVE/RULED_OUT)
- `actes_ccam` : actes codes
- `veto_report` : score de contestabilite (0-10), issues detectees
- `completude` : checklist, score, verdict
- `ghm_estimation` : GHM, severite, valorisation estimee
- `controles_cpam` : contre-argumentations generees
## Deploiement
Service systemd inclus (`t2a-viewer.service`), config gunicorn (`gunicorn.conf.py`).
Auth HTTP Basic configurable via `T2A_DEMO_USER` / `T2A_DEMO_PASS`.

View File

@@ -1,254 +0,0 @@
#!/usr/bin/env python3
"""
Analyse structurelle detaillee des PDFs dans /home/dom/ai/t2a/input/
Utilise pdfplumber pour extraire texte, tableaux, headers et donnees personnelles.
"""
import pdfplumber
import os
import re
INPUT_DIR = "/home/dom/ai/t2a/input/"
REPORT_FILE = "/home/dom/ai/t2a/rapport_analyse_pdfs.md"
# Patterns pour detecter des donnees personnelles
PATTERNS = {
"telephone": re.compile(r'(?:\+?\d{1,3}[\s.-]?)?\(?\d{2,4}\)?[\s.-]?\d{2,4}[\s.-]?\d{2,4}[\s.-]?\d{0,4}'),
"email": re.compile(r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'),
"code_postal": re.compile(r'\b\d{5}\b'),
"numero_dossier": re.compile(r'\b\d{7,10}\b'),
"date": re.compile(r'\b\d{1,2}[/.-]\d{1,2}[/.-]\d{2,4}\b'),
"montant_euro": re.compile(r'\d+[\s.,]?\d*\s*[€]|\d+[\s.,]?\d*\s*EUR'),
}
def analyze_pdf(filepath):
"""Analyse complete d'un PDF."""
result = {
"filename": os.path.basename(filepath),
"filepath": filepath,
"pages": [],
"tables_all": [],
"full_text": "",
"headers_detected": [],
"personal_data": {},
"metadata": {},
}
with pdfplumber.open(filepath) as pdf:
result["metadata"] = {
"num_pages": len(pdf.pages),
"pdf_metadata": pdf.metadata if pdf.metadata else {},
}
for i, page in enumerate(pdf.pages):
page_info = {
"page_num": i + 1,
"width": page.width,
"height": page.height,
"text": "",
"tables": [],
"lines_count": 0,
"chars_count": 0,
"rects_count": 0,
"images_count": 0,
}
text = page.extract_text() or ""
page_info["text"] = text
page_info["lines_count"] = len(text.split('\n')) if text else 0
page_info["chars_count"] = len(page.chars) if page.chars else 0
page_info["rects_count"] = len(page.rects) if page.rects else 0
page_info["images_count"] = len(page.images) if page.images else 0
tables = page.extract_tables() or []
for t_idx, table in enumerate(tables):
table_info = {
"table_index": t_idx,
"page": i + 1,
"rows": len(table),
"cols": max(len(row) for row in table) if table else 0,
"data": table,
"header_row": table[0] if table else [],
}
page_info["tables"].append(table_info)
result["tables_all"].append(table_info)
result["pages"].append(page_info)
result["full_text"] += f"\n--- PAGE {i+1} ---\n{text}\n"
# Detecter les headers/sections
for line in result["full_text"].split('\n'):
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("--- PAGE"):
continue
if len(stripped) >= 3 and stripped == stripped.upper() and any(c.isalpha() for c in stripped):
result["headers_detected"].append(stripped)
elif len(stripped) < 80 and stripped[0].isupper() and ':' in stripped:
result["headers_detected"].append(stripped)
# Detecter les donnees personnelles
for pattern_name, pattern in PATTERNS.items():
matches = pattern.findall(result["full_text"])
if matches:
unique_matches = list(set(m.strip() for m in matches if len(m.strip()) > 3))
if unique_matches:
result["personal_data"][pattern_name] = unique_matches
return result
def format_table_for_md(table_data, max_rows=30):
"""Formate un tableau en Markdown."""
if not table_data:
return "_Tableau vide_"
lines = []
max_cols = max(len(row) for row in table_data)
normalized = []
for row in table_data[:max_rows]:
norm_row = []
for j in range(max_cols):
if j < len(row) and row[j] is not None:
cell = str(row[j]).replace('\n', ' ').replace('|', '/').strip()
norm_row.append(cell if cell else "")
else:
norm_row.append("")
normalized.append(norm_row)
lines.append("| " + " | ".join(normalized[0]) + " |")
lines.append("| " + " | ".join(["---"] * max_cols) + " |")
for row in normalized[1:]:
lines.append("| " + " | ".join(row) + " |")
if len(table_data) > max_rows:
lines.append(f"\n_... ({len(table_data) - max_rows} lignes supplementaires non affichees)_")
return "\n".join(lines)
def generate_report(analyses):
"""Genere le rapport Markdown."""
report = []
report.append("# Rapport d'analyse structurelle des PDFs")
report.append(f"\n**Repertoire analyse :** `{INPUT_DIR}`")
report.append(f"**Nombre de fichiers :** {len(analyses)}")
report.append("")
for idx, analysis in enumerate(analyses, 1):
report.append(f"\n{'='*80}")
report.append(f"## {idx}. {analysis['filename']}")
report.append(f"{'='*80}\n")
meta = analysis["metadata"]
report.append("### Metadonnees du PDF")
report.append(f"- **Nombre de pages :** {meta['num_pages']}")
if meta.get("pdf_metadata"):
for k, v in meta["pdf_metadata"].items():
if v:
report.append(f"- **{k} :** {v}")
report.append("")
report.append("### Structure par page")
for page in analysis["pages"]:
report.append(f"\n#### Page {page['page_num']}")
report.append(f"- **Dimensions :** {page['width']} x {page['height']} pts")
report.append(f"- **Lignes de texte :** {page['lines_count']}")
report.append(f"- **Caracteres (objets) :** {page['chars_count']}")
report.append(f"- **Rectangles :** {page['rects_count']}")
report.append(f"- **Images :** {page['images_count']}")
report.append(f"- **Tableaux detectes :** {len(page['tables'])}")
report.append("")
report.append("### Texte complet extrait")
report.append("```")
report.append(analysis["full_text"].strip())
report.append("```")
report.append("")
if analysis["tables_all"]:
report.append(f"### Tableaux detectes ({len(analysis['tables_all'])} au total)")
for t in analysis["tables_all"]:
report.append(f"\n#### Tableau {t['table_index']+1} (Page {t['page']}) - {t['rows']} lignes x {t['cols']} colonnes")
report.append("")
report.append(format_table_for_md(t["data"]))
report.append("")
else:
report.append("### Tableaux detectes")
report.append("_Aucun tableau detecte par pdfplumber._\n")
report.append("### Sections / Headers identifies")
if analysis["headers_detected"]:
seen = set()
for h in analysis["headers_detected"]:
if h not in seen:
report.append(f"- `{h}`")
seen.add(h)
else:
report.append("_Aucun header identifie._")
report.append("")
report.append("### Donnees personnelles detectees")
if analysis["personal_data"]:
for category, values in analysis["personal_data"].items():
report.append(f"\n**{category.replace('_', ' ').title()} :**")
for v in sorted(values):
report.append(f"- `{v}`")
else:
report.append("_Aucune donnee personnelle detectee._")
report.append("")
report.append(f"\n{'='*80}")
report.append("## Resume comparatif")
report.append(f"{'='*80}\n")
report.append("| Caracteristique | " + " | ".join(a["filename"] for a in analyses) + " |")
report.append("| --- | " + " | ".join(["---"] * len(analyses)) + " |")
report.append("| Pages | " + " | ".join(str(a["metadata"]["num_pages"]) for a in analyses) + " |")
report.append("| Tableaux | " + " | ".join(str(len(a["tables_all"])) for a in analyses) + " |")
report.append("| Headers | " + " | ".join(str(len(set(a["headers_detected"]))) for a in analyses) + " |")
report.append("| Longueur texte | " + " | ".join(str(len(a["full_text"])) + " chars" for a in analyses) + " |")
return "\n".join(report)
def main():
pdf_files = sorted([
os.path.join(INPUT_DIR, f)
for f in os.listdir(INPUT_DIR)
if f.lower().endswith('.pdf')
])
print(f"Fichiers PDF trouves : {len(pdf_files)}")
for f in pdf_files:
print(f" - {f}")
analyses = []
for filepath in pdf_files:
print(f"\nAnalyse de : {os.path.basename(filepath)} ...")
analysis = analyze_pdf(filepath)
analyses.append(analysis)
print(f" Pages: {analysis['metadata']['num_pages']}")
print(f" Tableaux: {len(analysis['tables_all'])}")
print(f" Headers: {len(set(analysis['headers_detected']))}")
print(f" Texte: {len(analysis['full_text'])} chars")
report = generate_report(analyses)
with open(REPORT_FILE, "w", encoding="utf-8") as f:
f.write(report)
print(f"\n{'='*60}")
print(f"Rapport ecrit dans : {REPORT_FILE}")
print(f"{'='*60}")
print("\n")
print(report)
if __name__ == "__main__":
main()

View File

@@ -1,506 +0,0 @@
#!/usr/bin/env python3
"""Benchmark CPAM TIM — test complet multi-modèles sur dossiers réels.
Teste generate_cpam_response() avec chaque modèle local candidat
pour évaluer : validité JSON, compliance TIM, cohérence bio, codes inventés.
Usage:
python benchmark_cpam_models.py [dossier_name]
"""
import json
import logging
import os
import sys
import time
import importlib
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-5s %(name)s%(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("benchmark_cpam")
# Modèles locaux à tester (pas de cloud)
MODELS_TO_TEST = [
"gemma3:27b",
"gemma3:27b-it-qat",
"qwen3:32b",
"qwen3:14b",
"mistral-small3.2:24b",
"llama3.3:70b",
]
# Dossier de test par défaut
DEFAULT_DOSSIER = "183_23087212"
# Seuils bio connus (ground truth pour vérification)
BIO_GROUND_TRUTH = {
"Créatinine": {"valeur": 84, "norme_min": 50, "norme_max": 120, "status": "NORMAL"},
"Sodium": {"valeur": 140, "norme_min": 135, "norme_max": 145, "status": "NORMAL"},
"Potassium": {"valeur": 3.9, "norme_min": 3.5, "norme_max": 5.0, "status": "NORMAL"},
"Hémoglobine": {"valeur": 12.6, "norme_min": 12, "norme_max": 17, "status": "NORMAL"},
"Plaquettes": {"valeur": 268, "norme_min": 150, "norme_max": 400, "status": "NORMAL"},
"Glycémie": {"valeur": 4.8, "norme_min": 3.9, "norme_max": 5.5, "status": "NORMAL"},
}
def load_dossier(name: str):
"""Charge un dossier JSON depuis output/structured/."""
from src.config import DossierMedical
base = Path(__file__).parent / "output" / "structured" / name
fusionne = list(base.glob("*_fusionne_cim10.json"))
json_files = fusionne if fusionne else sorted(base.glob("*.json"))
if not json_files:
logger.error("Aucun JSON trouvé pour %s", name)
return None
with open(json_files[0], encoding="utf-8") as f:
data = json.load(f)
return DossierMedical(**data)
def set_model(model_name: str):
"""Force le modèle CPAM dans la config au runtime."""
import src.config as cfg
import src.medical.ollama_client as oc
cfg.OLLAMA_MODELS["cpam"] = model_name
# Timeout adapté aux gros modèles locaux (600s = 10 min)
cfg.OLLAMA_TIMEOUT = 600
oc.OLLAMA_TIMEOUT = 600 # Propagation directe (importé par valeur)
logger.info("Modèle CPAM forcé → %s (timeout=600s)", model_name)
def check_model_available(model_name: str) -> bool:
"""Vérifie si le modèle est disponible localement dans Ollama."""
import requests
try:
resp = requests.get(f"{os.environ.get('OLLAMA_URL', 'http://localhost:11434')}/api/tags", timeout=5)
if resp.status_code == 200:
models = [m["name"] for m in resp.json().get("models", [])]
# Vérifier correspondance exacte ou avec :latest
for m in models:
if m == model_name or m == f"{model_name}:latest":
return True
# Gérer les cas comme "gemma3:27b" qui match "gemma3:27b"
if model_name in m:
return True
return False
except Exception:
return False
def is_tim_format(result: dict) -> bool:
"""Vérifie si le résultat est au format TIM."""
return isinstance(result, dict) and "moyens_defense" in result
def check_bio_coherence(result: dict) -> list[dict]:
"""Vérifie la cohérence bio/diagnostic dans les sorties du modèle.
Returns:
Liste d'erreurs trouvées avec détails.
"""
errors = []
if not isinstance(result, dict):
return errors
# Sérialiser tout le résultat en texte pour chercher les erreurs
full_text = json.dumps(result, ensure_ascii=False).lower()
# Vérification 1: Créatinine 84 qualifiée d'anormale
creat_patterns = [
"insuffisance rénale",
"ira", "irc",
"fonction rénale altérée", "fonction rénale dégradée",
"créatinine élevée", "creatinine élevée",
"créatinine augmentée", "hypercréatininémie",
]
# Chercher si créatinine 84 est associée à un diagnostic d'IR
if "84" in full_text and "créatinine" in full_text:
# Chercher dans les arguments et preuves
for pattern in creat_patterns:
if pattern in full_text:
errors.append({
"type": "BIO_HALLUCINATION",
"severity": "CRITICAL",
"detail": f"Créatinine 84 µmol/L (NORMAL 50-120) qualifiée comme '{pattern}'",
"ground_truth": "Créatinine 84 = NORMAL",
})
break
# Vérification 2: confrontation_bio cohérente
confrontation = result.get("confrontation_bio", [])
for entry in confrontation:
if not isinstance(entry, dict):
continue
verdict = str(entry.get("verdict", "")).upper()
test = str(entry.get("test", "")).lower()
valeur = entry.get("valeur")
# Vérifier contre ground truth
for gt_test, gt_data in BIO_GROUND_TRUTH.items():
if gt_test.lower() in test:
if gt_data["status"] == "NORMAL" and "confirmé" in verdict.lower():
errors.append({
"type": "CONFRONTATION_ERROR",
"severity": "CRITICAL",
"detail": f"{gt_test} = {gt_data['valeur']} (NORMAL) mais verdict = {verdict}",
"ground_truth": f"{gt_test} norme [{gt_data['norme_min']}-{gt_data['norme_max']}]",
})
# Vérification 3: codes_non_defendables
codes_nd = result.get("codes_non_defendables", [])
if isinstance(codes_nd, list):
# Vérifier que N17.9 (IRA) est signalé comme non défendable
# car créatinine 84 = NORMAL
nd_codes = [c.get("code", "") for c in codes_nd if isinstance(c, dict)]
# Chercher si le modèle défend N17.9 malgré bio normale
moyens = result.get("moyens_defense", [])
for m in moyens:
if not isinstance(m, dict):
continue
titre = str(m.get("titre", "")).upper()
argument = str(m.get("argument", "")).upper()
for code in ["N17", "N19"]:
if code in titre or code in argument:
# Le modèle défend un code d'IR — vérifier la créatinine
if code not in " ".join(nd_codes):
errors.append({
"type": "DEFENDS_UNDEFENDABLE",
"severity": "HIGH",
"detail": f"Code {code} (IRA/IR) défendu dans moyens_defense malgré créatinine 84 (NORMAL)",
"ground_truth": "Créatinine 84 = NORMAL → N17/N19 non défendable sur base bio",
})
return errors
def check_code_validity(result: dict) -> list[dict]:
"""Vérifie que les codes CIM-10 utilisés sont plausibles."""
import re
errors = []
if not isinstance(result, dict):
return errors
full_text = json.dumps(result, ensure_ascii=False)
# Extraire tous les codes CIM-10 mentionnés
codes = set(re.findall(r'\b([A-Z]\d{2}(?:\.\d{1,2})?)\b', full_text))
# Codes suspects connus
suspicious_codes = {
"Q61.9": "Maladie polykystique — probablement inventé pour Bricker fragile",
"Z45.80": "Code Z45.8 existe mais Z45.80 est suspect (vérifier)",
}
for code in codes:
if code in suspicious_codes:
errors.append({
"type": "SUSPICIOUS_CODE",
"severity": "MEDIUM",
"detail": f"Code {code}: {suspicious_codes[code]}",
})
return errors
def evaluate_tim_structure(result: dict) -> dict:
"""Évalue la complétude de la structure TIM."""
scores = {}
if not is_tim_format(result):
return {"format": "LEGACY", "tim_compliant": False}
scores["format"] = "TIM"
scores["tim_compliant"] = True
# Champs obligatoires TIM
required_fields = [
"objet", "rappel_faits", "moyens_defense", "confrontation_bio",
"asymetrie_information", "reponse_points_cpam", "codes_non_defendables",
"references", "conclusion_dispositive",
]
present = []
missing = []
for field in required_fields:
if result.get(field):
present.append(field)
else:
missing.append(field)
scores["fields_present"] = len(present)
scores["fields_total"] = len(required_fields)
scores["fields_missing"] = missing
# Qualité des moyens de défense
moyens = result.get("moyens_defense", [])
scores["moyens_count"] = len(moyens)
total_preuves = 0
preuves_with_ref = 0
for m in moyens:
if isinstance(m, dict):
for p in m.get("preuves", []):
if isinstance(p, dict):
total_preuves += 1
if p.get("ref"):
preuves_with_ref += 1
scores["preuves_count"] = total_preuves
scores["preuves_with_ref"] = preuves_with_ref
# Confrontation bio
confrontation = result.get("confrontation_bio", [])
scores["confrontation_count"] = len(confrontation) if isinstance(confrontation, list) else 0
# Codes non défendables
codes_nd = result.get("codes_non_defendables", [])
scores["codes_nd_count"] = len(codes_nd) if isinstance(codes_nd, list) else 0
# Références
refs = result.get("references", [])
scores["refs_count"] = len(refs) if isinstance(refs, list) else 0
# Conclusion dispositive
conclusion = result.get("conclusion_dispositive", "")
scores["conclusion_len"] = len(conclusion)
scores["has_maintien"] = "maintien" in conclusion.lower() if conclusion else False
return scores
def run_benchmark_for_model(model_name: str, dossier_name: str) -> dict:
"""Lance le pipeline CPAM complet pour un modèle donné."""
from src.control.cpam_response import generate_cpam_response
from src.control.cpam_validation import _is_new_tim_format
result_data = {
"model": model_name,
"dossier": dossier_name,
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
}
# Charger le dossier
dossier = load_dossier(dossier_name)
if not dossier:
result_data["error"] = "Dossier non trouvé"
return result_data
if not dossier.controles_cpam:
result_data["error"] = "Pas de contrôle CPAM"
return result_data
controle = dossier.controles_cpam[0]
result_data["ogc"] = controle.numero_ogc
result_data["titre"] = controle.titre
# Forcer le modèle
set_model(model_name)
# Lancer le pipeline complet
logger.info("=" * 70)
logger.info("BENCHMARK : %s → dossier %s", model_name, dossier_name)
logger.info("=" * 70)
t0 = time.time()
try:
text, parsed, rag_sources = generate_cpam_response(dossier, controle)
elapsed = time.time() - t0
except Exception as e:
elapsed = time.time() - t0
result_data["error"] = str(e)
result_data["elapsed_s"] = round(elapsed, 1)
logger.exception("Erreur pipeline pour %s", model_name)
return result_data
result_data["elapsed_s"] = round(elapsed, 1)
result_data["text_len"] = len(text)
result_data["rag_sources"] = len(rag_sources)
result_data["quality_tier"] = controle.quality_tier or "?"
result_data["requires_review"] = controle.requires_review
if parsed is None:
result_data["error"] = "LLM a retourné None"
result_data["json_valid"] = False
return result_data
result_data["json_valid"] = True
result_data["is_tim"] = is_tim_format(parsed)
# Évaluation structure TIM
tim_eval = evaluate_tim_structure(parsed)
result_data["tim_eval"] = tim_eval
# Vérification cohérence bio
bio_errors = check_bio_coherence(parsed)
result_data["bio_errors"] = bio_errors
result_data["bio_errors_count"] = len(bio_errors)
result_data["bio_critical_count"] = len([e for e in bio_errors if e["severity"] == "CRITICAL"])
# Vérification codes
code_errors = check_code_validity(parsed)
result_data["code_errors"] = code_errors
result_data["code_errors_count"] = len(code_errors)
# Sauvegarder la sortie brute
result_data["parsed_response"] = parsed
result_data["text_output"] = text[:3000] # Tronquer pour lisibilité
return result_data
def print_summary(results: list[dict]):
"""Affiche un tableau résumé comparatif."""
print("\n" + "=" * 100)
print("BENCHMARK CPAM TIM — RÉSUMÉ COMPARATIF")
print("=" * 100)
# En-tête
header = (
f"{'Modèle':<25} {'JSON':>4} {'TIM':>4} {'Tier':>4} {'Temps':>7} "
f"{'Moyens':>6} {'Bio':>4} {'ND':>3} {'Refs':>4} {'Chars':>6} "
f"{'BioErr':>6} {'CritE':>5}"
)
print(header)
print("-" * 100)
for r in results:
if "error" in r and r.get("json_valid") is None:
print(f"{r['model']:<25} ERREUR: {r['error']}")
continue
tim_eval = r.get("tim_eval", {})
print(
f"{r['model']:<25} "
f"{'OK' if r.get('json_valid') else 'FAIL':>4} "
f"{'OK' if r.get('is_tim') else 'NO':>4} "
f"{r.get('quality_tier', '?'):>4} "
f"{r.get('elapsed_s', 0):>6.0f}s "
f"{tim_eval.get('moyens_count', 0):>6} "
f"{tim_eval.get('confrontation_count', 0):>4} "
f"{tim_eval.get('codes_nd_count', 0):>3} "
f"{tim_eval.get('refs_count', 0):>4} "
f"{r.get('text_len', 0):>6} "
f"{r.get('bio_errors_count', 0):>6} "
f"{r.get('bio_critical_count', 0):>5}"
)
# Détail des erreurs bio par modèle
print("\n" + "=" * 100)
print("DÉTAIL DES ERREURS BIOLOGIQUES")
print("=" * 100)
for r in results:
errors = r.get("bio_errors", [])
if not errors:
print(f"\n{r['model']}: ✓ Aucune erreur bio détectée")
continue
print(f"\n{r['model']}: ✗ {len(errors)} erreur(s)")
for e in errors:
severity_icon = "🔴" if e["severity"] == "CRITICAL" else "🟡" if e["severity"] == "HIGH" else ""
print(f" {severity_icon} [{e['severity']}] {e['type']}: {e['detail']}")
if "ground_truth" in e:
print(f" Vérité terrain: {e['ground_truth']}")
# Détail codes suspects
print("\n" + "=" * 100)
print("CODES CIM-10 SUSPECTS")
print("=" * 100)
for r in results:
code_errors = r.get("code_errors", [])
if not code_errors:
print(f"\n{r['model']}: ✓ Aucun code suspect")
continue
print(f"\n{r['model']}: ✗ {len(code_errors)} code(s) suspect(s)")
for e in code_errors:
print(f"{e['detail']}")
# Champs TIM manquants
print("\n" + "=" * 100)
print("COMPLIANCE FORMAT TIM")
print("=" * 100)
for r in results:
tim_eval = r.get("tim_eval", {})
if not tim_eval:
print(f"\n{r['model']}: N/A")
continue
missing = tim_eval.get("fields_missing", [])
total = tim_eval.get("fields_total", 9)
present = tim_eval.get("fields_present", 0)
status = "✓ COMPLET" if not missing else f"{present}/{total} champs"
print(f"\n{r['model']}: {status}")
if missing:
print(f" Manquants: {', '.join(missing)}")
if tim_eval.get("has_maintien"):
print(f" ✓ Conclusion dispositive avec demande de maintien")
elif tim_eval.get("conclusion_len", 0) > 0:
print(f" ⚠ Conclusion présente ({tim_eval['conclusion_len']} chars) mais sans 'maintien'")
else:
print(f" ✗ Pas de conclusion dispositive")
def main():
dossier_name = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_DOSSIER
# Vérifier quels modèles sont disponibles
available = []
unavailable = []
for model in MODELS_TO_TEST:
if check_model_available(model):
available.append(model)
else:
unavailable.append(model)
print(f"Modèles disponibles: {len(available)}/{len(MODELS_TO_TEST)}")
for m in available:
print(f"{m}")
for m in unavailable:
print(f"{m} (non trouvé)")
if not available:
print("ERREUR: Aucun modèle local disponible")
sys.exit(1)
print(f"\nDossier de test: {dossier_name}")
print(f"Début du benchmark...\n")
results = []
for model in available:
try:
result = run_benchmark_for_model(model, dossier_name)
results.append(result)
# Sauvegarder les résultats intermédiaires
output_path = Path(__file__).parent / "output" / "benchmark_cpam_tim.json"
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2, default=str)
except Exception as e:
logger.exception("Erreur fatale pour %s", model)
results.append({"model": model, "error": str(e)})
# Résumé comparatif
print_summary(results)
# Sauvegarder les résultats finaux
output_path = Path(__file__).parent / "output" / "benchmark_cpam_tim.json"
with open(output_path, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2, default=str)
print(f"\nRésultats détaillés sauvegardés dans: {output_path}")
if __name__ == "__main__":
main()

View File

@@ -1,472 +0,0 @@
#!/usr/bin/env python3
"""Comparaison qualité CPAM : multi-modèles sur 3 dossiers.
Génère la contre-argumentation CPAM avec plusieurs modèles et compare :
- Longueur et densité des arguments
- Présence des 3 axes (médical, asymétrie, réglementaire)
- Citations de preuves du dossier
- Références aux sources RAG
- Mots-clés d'asymétrie d'information
"""
import json
import re
import sys
import time
from pathlib import Path
import requests
STRUCTURED_DIR = Path("output/structured")
OLLAMA_URL = "http://localhost:11434"
MODELS = ["gemma3:12b-v2"] # 12b avec nouveau prompt nuancé
TIMEOUTS = {
"gemma3:12b": 120,
"gemma3:27b": 300,
"qwen3:14b": 180,
"mistral-small3.2:24b": 300,
}
# 3 dossiers variés : DP+DA, DAS long, DP court
TEST_DOSSIERS = [
"183_23087212", # DP+DA contestés
"228_23176885", # DAS seul, arg long (1921c)
"153_23102610", # DP seul, arg court
]
def load_dossier(dossier_name: str) -> dict | None:
dossier_dir = STRUCTURED_DIR / dossier_name
if not dossier_dir.exists():
return None
for f in list(dossier_dir.glob("*_fusionne_cim10.json")) + sorted(dossier_dir.glob("*_cim10.json")):
return json.loads(f.read_text())
return None
def build_prompt(data: dict, controle: dict, sources: list[dict]) -> str:
"""Reconstruit le prompt CPAM (identique au pipeline)."""
# Import du vrai builder pour garantir la cohérence
sys.path.insert(0, str(Path(__file__).parent))
from src.config import ControleCPAM, DossierMedical
from src.control.cpam_response import _build_cpam_prompt
dossier = DossierMedical.model_validate(data)
ctrl = ControleCPAM.model_validate(controle)
return _build_cpam_prompt(dossier, ctrl, sources)
# Modèles incompatibles avec format:json d'Ollama (mode thinking)
NO_FORMAT_JSON_MODELS = {"qwen3:14b", "qwen3:8b", "qwen3:32b"}
def _parse_json_from_text(raw: str) -> dict | None:
"""Parse du JSON depuis une réponse brute (avec ou sans markdown)."""
text = raw.strip()
# Retirer bloc markdown ```json ... ```
if text.startswith("```"):
first_nl = text.find("\n")
if first_nl != -1:
text = text[first_nl + 1:]
if text.rstrip().endswith("```"):
text = text.rstrip()[:-3]
text = text.strip()
# Essayer tel quel
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Trouver le premier { ... dernier }
brace_start = text.find("{")
brace_end = text.rfind("}")
if brace_start != -1 and brace_end > brace_start:
try:
return json.loads(text[brace_start:brace_end + 1])
except json.JSONDecodeError:
pass
return None
def call_ollama(prompt: str, model: str) -> tuple[dict | None, float, str]:
"""Appelle Ollama et retourne (parsed_json, duration_s, raw_text)."""
timeout = TIMEOUTS.get(model, 180)
use_format_json = model not in NO_FORMAT_JSON_MODELS
# Pour Qwen3 : ajouter /no_think pour désactiver le mode thinking
actual_prompt = prompt
if model in NO_FORMAT_JSON_MODELS:
actual_prompt = prompt + "\n/no_think"
payload = {
"model": model,
"prompt": actual_prompt,
"stream": False,
"options": {
"temperature": 0.1,
"num_predict": 4000,
},
}
if use_format_json:
payload["format"] = "json"
t0 = time.time()
try:
response = requests.post(
f"{OLLAMA_URL}/api/generate",
json=payload,
timeout=timeout,
)
response.raise_for_status()
duration = time.time() - t0
raw = response.json().get("response", "")
parsed = _parse_json_from_text(raw)
return parsed, duration, raw
except json.JSONDecodeError:
duration = time.time() - t0
return None, duration, raw
except Exception as e:
duration = time.time() - t0
return None, duration, str(e)
def compute_metrics(parsed: dict | None) -> dict:
"""Calcule les métriques de qualité."""
if parsed is None:
return {"valid_json": False}
full_text = json.dumps(parsed, ensure_ascii=False)
# 3 axes présents ?
has_med = bool(parsed.get("contre_arguments_medicaux"))
has_asym = bool(parsed.get("contre_arguments_asymetrie"))
has_regl = bool(parsed.get("contre_arguments_reglementaires"))
has_3axes = has_med and has_asym and has_regl
# Longueurs par axe
len_med = len(str(parsed.get("contre_arguments_medicaux", "")))
len_asym = len(str(parsed.get("contre_arguments_asymetrie", "")))
len_regl = len(str(parsed.get("contre_arguments_reglementaires", "")))
len_total_args = len_med + len_asym + len_regl
# Fallback ancien format
if not has_3axes:
len_total_args = max(len_total_args, len(str(parsed.get("contre_arguments", ""))))
# Preuves du dossier
preuves = parsed.get("preuves_dossier", [])
n_preuves = len(preuves) if isinstance(preuves, list) else 0
# Références structurées
refs = parsed.get("references", [])
n_refs = len(refs) if isinstance(refs, list) else 0
# Références avec citation verbatim
n_refs_citation = 0
if isinstance(refs, list):
for r in refs:
if isinstance(r, dict) and r.get("citation") and len(str(r["citation"])) > 20:
n_refs_citation += 1
# Mots-clés d'asymétrie
full_lower = full_text.lower()
asymetrie_kw = [
"biologie", "imagerie", "scanner", "irm", "échographie",
"traitement", "médicament", "posologie",
"asymétrie", "non transmis", "n'avait pas", "n'a pas eu accès",
"imc", "antécédent", "crp", "hémoglobine", "leucocytes",
]
n_asymetrie = sum(1 for kw in asymetrie_kw if kw in full_lower)
# Points d'accord réels
accord = str(parsed.get("points_accord", ""))
accord_real = bool(accord) and accord.lower().strip() not in ("aucun", "aucun.", "n/a", "")
# Conclusion non vide
conclusion = str(parsed.get("conclusion", ""))
has_conclusion = len(conclusion) > 20
return {
"valid_json": True,
"has_3axes": has_3axes,
"len_med": len_med,
"len_asym": len_asym,
"len_regl": len_regl,
"len_total_args": len_total_args,
"n_preuves": n_preuves,
"n_refs": n_refs,
"n_refs_citation": n_refs_citation,
"n_asymetrie": n_asymetrie,
"accord_real": accord_real,
"has_conclusion": has_conclusion,
"total_len": len(full_text),
}
def model_key(model: str) -> str:
"""Clé courte pour un modèle (ex: 'gemma3:12b''gemma3_12b')."""
return model.replace(":", "_").replace(".", "_")
def print_multi_model(results: list[dict], models: list[str]):
"""Affiche la comparaison multi-modèles."""
W = 140
col_w = 18
print("\n" + "=" * W)
print(f"COMPARAISON CPAM : {' vs '.join(models)}")
print("=" * W)
metric_labels = [
("Durée (s)", "duration", True),
("3 axes", "has_3axes", False),
("Args médicaux", "len_med", False),
("Args asymétrie", "len_asym", False),
("Args réglementaires", "len_regl", False),
("Total args (car.)", "len_total_args", False),
("Preuves structurées", "n_preuves", False),
("Références RAG", "n_refs", False),
("Refs verbatim", "n_refs_citation", False),
("Mots-clés asymétrie", "n_asymetrie", False),
("Points d'accord", "accord_real", False),
("Conclusion étayée", "has_conclusion", False),
("Longueur totale", "total_len", False),
]
for r in results:
print(f"\n{'' * W}")
print(f" {r['dossier']} / OGC {r['ogc']}{r['titre']}")
print(f" Argument CPAM : {r['arg_len']} car. | Prompt : {r['prompt_len']} car.")
print(f"{'' * W}")
# Vérifier validité
all_valid = True
for m in models:
mk = model_key(m)
metrics = r.get(f"metrics_{mk}", {})
if not metrics.get("valid_json", False):
dur = r.get(f"duration_{mk}", 0)
print(f" {m} : JSON INVALIDE ({dur:.1f}s)")
all_valid = False
if not all_valid:
continue
# Header
header = f" {'Métrique':<25}"
for m in models:
short = m.split(":")[0][:6] + ":" + m.split(":")[-1] if ":" in m else m[:col_w]
header += f" {short:>{col_w}}"
print(header)
print(f" {'' * (25 + (col_w + 1) * len(models))}")
for label, key, is_duration in metric_labels:
row = f" {label:<25}"
for m in models:
mk = model_key(m)
if is_duration:
val = r.get(f"duration_{mk}", 0)
row += f" {val:>{col_w - 1}.1f}s"
else:
metrics = r.get(f"metrics_{mk}", {})
val = metrics.get(key, 0)
if isinstance(val, bool):
row += f" {'Oui' if val else 'Non':>{col_w}}"
else:
row += f" {val:>{col_w}}"
print(row)
# Synthèse globale
print(f"\n{'=' * W}")
print("SYNTHÈSE GLOBALE")
print(f"{'=' * W}")
# Filtrer les résultats valides pour tous les modèles
valid = []
for r in results:
all_ok = all(r.get(f"metrics_{model_key(m)}", {}).get("valid_json", False) for m in models)
if all_ok:
valid.append(r)
if not valid:
print(" Aucun résultat valide pour tous les modèles.")
return
n = len(valid)
print(f" Dossiers comparés : {n}")
# Header synthèse
header = f"\n {'Métrique':<25}"
for m in models:
short = m.split(":")[0][:6] + ":" + m.split(":")[-1] if ":" in m else m[:col_w]
header += f" {short:>{col_w}}"
header += f" {'Meilleur':>{col_w}}"
print(header)
print(f" {'' * (25 + (col_w + 1) * (len(models) + 1))}")
# Durée
row = f" {'Durée moy. (s)':<25}"
dur_vals = {}
for m in models:
mk = model_key(m)
avg_dur = sum(r.get(f"duration_{mk}", 0) for r in valid) / n
dur_vals[m] = avg_dur
row += f" {avg_dur:>{col_w - 1}.1f}s"
best = min(dur_vals, key=dur_vals.get)
row += f" {best:>{col_w}}"
print(row)
# Métriques (higher is better)
for label, key in [
("Total args (car.)", "len_total_args"),
("Preuves structurées", "n_preuves"),
("Références RAG", "n_refs"),
("Refs verbatim", "n_refs_citation"),
("Mots-clés asymétrie", "n_asymetrie"),
]:
row = f" {label:<25}"
vals = {}
for m in models:
mk = model_key(m)
avg_val = sum(r.get(f"metrics_{mk}", {}).get(key, 0) for r in valid) / n
vals[m] = avg_val
row += f" {avg_val:>{col_w}.1f}"
best = max(vals, key=vals.get)
row += f" {best:>{col_w}}"
print(row)
# Booléens (count True)
for label, key in [
("3 axes", "has_3axes"),
("Points d'accord", "accord_real"),
]:
row = f" {label:<25}"
vals = {}
for m in models:
mk = model_key(m)
cnt = sum(1 for r in valid if r.get(f"metrics_{mk}", {}).get(key, False))
vals[m] = cnt
row += f" {f'{cnt}/{n}':>{col_w}}"
best = max(vals, key=vals.get)
row += f" {best:>{col_w}}"
print(row)
# Durées totales
print()
fastest = min(models, key=lambda m: sum(r.get(f"duration_{model_key(m)}", 0) for r in valid))
fastest_dur = sum(r.get(f"duration_{model_key(fastest)}", 0) for r in valid)
for m in models:
mk = model_key(m)
total = sum(r.get(f"duration_{mk}", 0) for r in valid)
ratio = total / fastest_dur if fastest_dur > 0 else 0
print(f" {m:<25} total={total:.0f}s (x{ratio:.1f})")
print()
def main():
# Charger les résultats précédents (all_models)
prev_file = Path("output/compare_cpam_all_models.json")
prev_data = {}
if prev_file.exists():
for entry in json.loads(prev_file.read_text()):
prev_data[entry["dossier"]] = entry
# On compare l'ancien 12b (ancien prompt) vs le nouveau 12b-v2 (nouveau prompt nuancé)
# + 27b comme référence nuance
ref_models = ["gemma3:12b", "gemma3:27b"]
all_models = ref_models + MODELS
print("=" * 100)
print(f"Comparaison qualité CPAM : {' / '.join(all_models)}")
print(f"Dossiers : {', '.join(TEST_DOSSIERS)}")
print(f"Test : gemma3:12b avec NOUVEAU prompt nuancé (v2)")
print(f"Résultats précédents : {'oui' if prev_data else 'non'}")
print("=" * 100)
results = []
for dossier_name in TEST_DOSSIERS:
data = load_dossier(dossier_name)
if not data:
print(f"\nERREUR : {dossier_name} non trouvé")
continue
controles = [c for c in data.get("controles_cpam", []) if c.get("arg_ucr")]
if not controles:
print(f"\nERREUR : {dossier_name} — pas de contrôle CPAM")
continue
controle = controles[0]
sources = [
{
"document": s.get("document", ""),
"page": s.get("page"),
"code": s.get("code"),
"extrait": s.get("extrait", ""),
}
for s in controle.get("sources_reponse", [])
]
prompt = build_prompt(data, controle, sources)
print(f"\n[{dossier_name}] OGC {controle['numero_ogc']}{controle.get('titre', '')}")
print(f" Prompt : {len(prompt)} car. | Arg CPAM : {len(controle.get('arg_ucr', ''))} car.")
result = {
"dossier": dossier_name,
"ogc": controle["numero_ogc"],
"titre": controle.get("titre", ""),
"arg_len": len(controle.get("arg_ucr", "")),
"prompt_len": len(prompt),
}
# Réutiliser les résultats précédents pour les modèles de référence
prev = prev_data.get(dossier_name)
if prev:
for old_model in ref_models:
mk = model_key(old_model)
result[f"duration_{mk}"] = prev.get(f"duration_{mk}", 0)
result[f"metrics_{mk}"] = prev.get(f"metrics_{mk}", {})
result[f"response_{mk}"] = prev.get(f"response_{mk}")
dur = result[f"duration_{mk}"]
is_valid = result[f"metrics_{mk}"].get("valid_json", False)
print(f"{old_model} ... (précédent) {'OK' if is_valid else 'FAIL'} ({dur:.1f}s)")
# Tester le 12b-v2 (nouveau prompt) — appelle gemma3:12b avec le prompt modifié
for model_label in MODELS:
mk = model_key(model_label)
actual_model = "gemma3:12b" # même modèle, nouveau prompt
print(f"{model_label} (nouveau prompt) ...", end=" ", flush=True)
parsed, dur, raw = call_ollama(prompt, actual_model)
status = "OK" if parsed else "FAIL"
print(f"{status} ({dur:.1f}s)")
result[f"duration_{mk}"] = dur
result[f"metrics_{mk}"] = compute_metrics(parsed)
result[f"response_{mk}"] = parsed
results.append(result)
# Affichage
print_multi_model(results, all_models)
# Sauvegarde
output_file = Path("output/compare_cpam_prompt_v2.json")
output_file.parent.mkdir(parents=True, exist_ok=True)
save_data = []
for r in results:
entry = {
"dossier": r["dossier"],
"ogc": r["ogc"],
"titre": r["titre"],
}
for m in all_models:
mk = model_key(m)
entry[f"duration_{mk}"] = r.get(f"duration_{mk}", 0)
entry[f"metrics_{mk}"] = r.get(f"metrics_{mk}", {})
entry[f"response_{mk}"] = r.get(f"response_{mk}")
save_data.append(entry)
output_file.write_text(json.dumps(save_data, ensure_ascii=False, indent=2))
print(f"Résultats sauvegardés dans {output_file}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,437 @@
# Règles de complétude documentaire DIM
# Pour chaque famille diagnostique : éléments obligatoires/recommandés
# qui doivent être présents dans le dossier pour justifier le code.
#
# Catégories : biologie | imagerie | document | acte | clinique
# Importance : obligatoire | recommande
# match_type : bio (biologie_cle.test), imagerie (imagerie.type), document (doc_types),
# clinique (sejour.imc/poids/taille), acte (actes_ccam)
#
# Seuils (optionnel) : confrontation valeur ↔ diagnostic
# type: below | above | range
# value / value_m / value_f / range_min / range_max
# message_ok / message_ko
version: 2
# --- Règles par préfixe CIM-10 ---
diagnostics:
denutrition:
prefixes: ["E43", "E44", "E46"]
libelle_famille: "Dénutrition"
items:
- categorie: biologie
element: Albumine
match_bio: ["albumine"]
importance: obligatoire
impact_cpam: "Albumine exigée par la CPAM pour valider une dénutrition (critère ATIH)"
seuil:
code_filter: "E43"
type: below
value: 30
message_ok: "Albumine < 30 g/L confirme la dénutrition sévère"
message_ko: "Albumine ≥ 30 g/L : dénutrition sévère non confirmée biologiquement"
- categorie: biologie
element: Albumine
match_bio: ["albumine"]
importance: obligatoire
impact_cpam: "Albumine exigée pour dénutrition modérée"
seuil:
code_filter: "E44"
type: range
range_min: 30
range_max: 35
message_ok: "Albumine entre 30-35 g/L confirme la dénutrition modérée"
message_ko: "Albumine hors plage 30-35 g/L pour dénutrition modérée"
- categorie: clinique
element: IMC
match_clinique: imc
importance: obligatoire
impact_cpam: "IMC nécessaire pour classifier le degré de dénutrition"
seuil:
code_filter: "E43"
type: below
value: 18.5
message_ok: "IMC < 18.5 confirme la dénutrition sévère"
message_ko: "IMC ≥ 18.5 : dénutrition sévère non confirmée"
- categorie: clinique
element: IMC
match_clinique: imc
importance: obligatoire
impact_cpam: "IMC nécessaire pour dénutrition modérée"
seuil:
code_filter: "E44"
type: range
range_min: 18.5
range_max: 21
message_ok: "IMC entre 18.5-21 confirme la dénutrition modérée"
message_ko: "IMC hors plage 18.5-21 pour dénutrition modérée"
- categorie: biologie
element: Préalbumine
match_bio: ["prealbumine", "préalbumine", "transthyretine", "transthyrétine"]
importance: recommande
impact_cpam: "Renforce la preuve de dénutrition si albumine limite"
anemie:
prefixes: ["D50", "D62", "D63", "D64"]
libelle_famille: "Anémie"
items:
- categorie: biologie
element: Hémoglobine
match_bio: ["hemoglobine", "hémoglobine", "hb"]
importance: obligatoire
impact_cpam: "Hémoglobine indispensable pour confirmer et qualifier une anémie"
seuil:
type: below
value_m: 13
value_f: 12
message_ok: "Hémoglobine basse confirme l'anémie"
message_ko: "Hémoglobine normale : anémie non confirmée biologiquement"
- categorie: biologie
element: Ferritine
match_bio: ["ferritine"]
importance: recommande
impact_cpam: "Permet de typer l'anémie (carentielle vs inflammatoire)"
- categorie: biologie
element: VGM
match_bio: ["vgm", "volume globulaire moyen"]
importance: recommande
impact_cpam: "Oriente l'étiologie (microcytaire/macrocytaire)"
insuffisance_renale:
prefixes: ["N17", "N18", "N19"]
libelle_famille: "Insuffisance rénale"
items:
- categorie: biologie
element: Créatinine
match_bio: ["creatinine", "créatinine", "creat"]
importance: obligatoire
impact_cpam: "Créatinine obligatoire pour confirmer une insuffisance rénale"
seuil:
type: above
value: 120
message_ok: "Créatinine > 120 µmol/L confirme l'insuffisance rénale"
message_ko: "Créatinine ≤ 120 µmol/L : IR non confirmée biologiquement"
- categorie: biologie
element: DFG
match_bio: ["dfg", "clairance", "dfge", "débit de filtration"]
importance: recommande
impact_cpam: "Permet de stadifier l'IR selon KDIGO"
- categorie: biologie
element: Urée
match_bio: ["uree", "urée"]
importance: recommande
impact_cpam: "Élément complémentaire de la fonction rénale"
sepsis:
prefixes: ["A40", "A41"]
libelle_famille: "Sepsis / Septicémie"
items:
- categorie: biologie
element: CRP
match_bio: ["crp", "proteine c reactive", "protéine c réactive"]
importance: obligatoire
impact_cpam: "Marqueur inflammatoire essentiel pour documenter un sepsis"
seuil:
type: above
value: 50
message_ok: "CRP > 50 mg/L confirme le syndrome inflammatoire"
message_ko: "CRP ≤ 50 mg/L : syndrome inflammatoire non significatif"
- categorie: biologie
element: Leucocytes
match_bio: ["leucocytes", "gb", "globules blancs"]
importance: obligatoire
impact_cpam: "Leucocytose ou leucopénie attendue dans le sepsis"
seuil:
type: outside_range
range_min: 4
range_max: 10
message_ok: "Leucocytes hors norme (< 4 ou > 10 G/L) : compatible avec sepsis"
message_ko: "Leucocytes normaux (4-10 G/L) : sepsis non confirmé biologiquement"
- categorie: biologie
element: Procalcitonine
match_bio: ["procalcitonine", "pct"]
importance: recommande
impact_cpam: "Marqueur spécifique d'infection bactérienne, renforce la preuve"
- categorie: biologie
element: Hémocultures
match_bio: ["hemoculture", "hémoculture", "hemocultures", "hémocultures"]
importance: recommande
impact_cpam: "Documentation bactériologique du sepsis"
troubles_electrolytiques:
prefixes: ["E87"]
libelle_famille: "Troubles électrolytiques"
items:
- categorie: biologie
element: Sodium
match_bio: ["sodium", "natremie", "natrémie", "na"]
importance: obligatoire
impact_cpam: "Ionogramme obligatoire pour justifier un trouble électrolytique"
seuil:
type: below
value: 135
message_ok: "Sodium < 135 mmol/L confirme l'hyponatrémie"
message_ko: "Sodium ≥ 135 mmol/L : hyponatrémie non confirmée"
- categorie: biologie
element: Potassium
match_bio: ["potassium", "kaliemie", "kaliémie", "k"]
importance: obligatoire
impact_cpam: "Ionogramme obligatoire pour justifier un trouble électrolytique"
seuil:
type: outside_range
range_min: 3.5
range_max: 5.0
message_ok: "Potassium hors norme : trouble confirmé"
message_ko: "Potassium normal (3.5-5.0) : trouble non confirmé"
diabete:
prefixes: ["E10", "E11"]
libelle_famille: "Diabète"
items:
- categorie: biologie
element: HbA1c
match_bio: ["hba1c", "hemoglobine glyquee", "hémoglobine glyquée"]
importance: recommande
impact_cpam: "HbA1c attendue pour documenter l'équilibre glycémique"
- categorie: biologie
element: Glycémie
match_bio: ["glycemie", "glycémie", "glucose"]
importance: recommande
impact_cpam: "Glycémie de base pour confirmer le diagnostic"
pancreatite:
prefixes: ["K85"]
libelle_famille: "Pancréatite aiguë"
items:
- categorie: biologie
element: Lipasémie
match_bio: ["lipase", "lipasemie", "lipasémie"]
importance: obligatoire
impact_cpam: "Lipase > 3N est le critère diagnostique de référence"
seuil:
type: above
value: 180
message_ok: "Lipase > 180 UI/L (3× la normale) confirme la pancréatite"
message_ko: "Lipase ≤ 180 UI/L : critère diagnostique non atteint"
- categorie: imagerie
element: Scanner abdominal
match_imagerie: ["scanner", "tdm", "tomodensitometrie"]
importance: recommande
impact_cpam: "Scanner recommandé pour évaluer la sévérité (Balthazar)"
embolie_pulmonaire:
prefixes: ["I26"]
libelle_famille: "Embolie pulmonaire"
items:
- categorie: imagerie
element: Angioscanner thoracique
match_imagerie: ["angioscanner", "scanner", "tdm", "angiotdm"]
importance: obligatoire
impact_cpam: "Imagerie indispensable pour confirmer une EP"
- categorie: biologie
element: D-dimères
match_bio: ["d-dimeres", "d-dimères", "ddimeres", "d dimeres"]
importance: recommande
impact_cpam: "D-dimères utiles si négatifs pour exclure, non suffisants seuls"
tumeurs_malignes:
prefixes: ["C"]
libelle_famille: "Tumeur maligne"
items:
- categorie: document
element: ANAPATH
match_document: ["anapath", "anatomopathologie", "biopsie"]
importance: obligatoire
impact_cpam: "Compte-rendu anatomopathologique exigé pour tout code C (tumeur maligne)"
pathologies_hepatiques:
prefixes: ["K70", "K71", "K72", "K73", "K74", "K75", "K76", "K77"]
libelle_famille: "Pathologie hépatique"
items:
- categorie: biologie
element: ASAT
match_bio: ["asat", "got", "aspartate aminotransferase"]
importance: obligatoire
impact_cpam: "Bilan hépatique obligatoire pour documenter une atteinte hépatique"
seuil:
type: above
value: 40
message_ok: "ASAT > 40 UI/L confirme la cytolyse hépatique"
message_ko: "ASAT ≤ 40 UI/L : cytolyse non confirmée"
- categorie: biologie
element: ALAT
match_bio: ["alat", "gpt", "alanine aminotransferase"]
importance: obligatoire
impact_cpam: "Bilan hépatique obligatoire"
seuil:
type: above
value: 40
message_ok: "ALAT > 40 UI/L confirme la cytolyse hépatique"
message_ko: "ALAT ≤ 40 UI/L : cytolyse non confirmée"
- categorie: biologie
element: Bilirubine
match_bio: ["bilirubine", "bili"]
importance: recommande
impact_cpam: "Bilirubine renforce la documentation d'une atteinte hépatique"
obesite:
prefixes: ["E66"]
libelle_famille: "Obésité"
items:
- categorie: clinique
element: IMC
match_clinique: imc
importance: obligatoire
impact_cpam: "IMC ≥ 30 indispensable pour coder une obésité"
seuil:
type: above
value: 30
message_ok: "IMC ≥ 30 confirme l'obésité"
message_ko: "IMC < 30 : obésité non confirmée"
- categorie: clinique
element: Poids
match_clinique: poids
importance: obligatoire
impact_cpam: "Poids nécessaire pour calculer l'IMC"
insuffisance_cardiaque:
prefixes: ["I50"]
libelle_famille: "Insuffisance cardiaque"
items:
- categorie: biologie
element: BNP / NT-proBNP
match_bio: ["bnp", "nt-probnp", "ntprobnp", "pro-bnp"]
importance: obligatoire
impact_cpam: "BNP/NT-proBNP attendu pour confirmer une insuffisance cardiaque"
seuil:
type: above
value: 100
message_ok: "BNP > 100 pg/mL (ou NT-proBNP > 300) confirme l'IC"
message_ko: "BNP ≤ 100 pg/mL : IC non confirmée biologiquement"
- categorie: imagerie
element: Échographie cardiaque
match_imagerie: ["echographie cardiaque", "échocardiographie", "echo coeur", "ett", "eto"]
importance: recommande
impact_cpam: "ETT recommandée pour documenter la FEVG"
# --- 8 NOUVELLES FAMILLES ---
avc_ait:
prefixes: ["I60", "I61", "I62", "I63", "I64", "G45"]
libelle_famille: "AVC / AIT"
items:
- categorie: imagerie
element: Scanner/IRM cérébral
match_imagerie: ["scanner cerebral", "irm cerebral", "irm cérébral", "scanner cérébral", "tdm cerebral", "tdm cérébral", "irm encephalique", "irm encéphalique"]
importance: obligatoire
impact_cpam: "Imagerie cérébrale indispensable pour confirmer un AVC/AIT"
- categorie: biologie
element: ECG
match_bio: ["ecg", "electrocardiogramme", "électrocardiogramme"]
importance: recommande
impact_cpam: "ECG recommandé pour rechercher une cause cardioembolique"
idm:
prefixes: ["I21", "I22"]
libelle_famille: "Infarctus du myocarde"
items:
- categorie: biologie
element: Troponine
match_bio: ["troponine", "tnc", "tni", "tnt", "troponine i", "troponine t", "troponine us"]
importance: obligatoire
impact_cpam: "Troponine obligatoire pour confirmer un IDM"
seuil:
type: above
value: 0.04
message_ok: "Troponine > 0.04 confirme la nécrose myocardique"
message_ko: "Troponine ≤ 0.04 : IDM non confirmé biologiquement"
- categorie: biologie
element: ECG
match_bio: ["ecg", "electrocardiogramme", "électrocardiogramme"]
importance: recommande
impact_cpam: "ECG recommandé pour caractériser l'IDM (ST+/ST-)"
- categorie: imagerie
element: Coronarographie
match_imagerie: ["coronarographie", "coronaro", "coro"]
importance: recommande
impact_cpam: "Coronarographie recommandée pour documenter les lésions"
pneumopathie:
prefixes: ["J12", "J13", "J14", "J15", "J16", "J17", "J18"]
libelle_famille: "Pneumopathie"
items:
- categorie: imagerie
element: Radio/Scanner thoracique
match_imagerie: ["radio thorax", "radiographie thoracique", "scanner thoracique", "tdm thoracique", "rx thorax", "radio pulmonaire"]
importance: obligatoire
impact_cpam: "Imagerie thoracique indispensable pour confirmer une pneumopathie"
- categorie: biologie
element: CRP
match_bio: ["crp", "proteine c reactive", "protéine c réactive"]
importance: recommande
impact_cpam: "CRP recommandée pour documenter le syndrome inflammatoire"
tvp:
prefixes: ["I80"]
libelle_famille: "Thrombose veineuse profonde"
items:
- categorie: imagerie
element: Écho-doppler veineux
match_imagerie: ["echo-doppler", "écho-doppler", "echo doppler", "écho doppler", "doppler veineux", "echodoppler"]
importance: obligatoire
impact_cpam: "Écho-doppler veineux indispensable pour confirmer une TVP"
insuffisance_respiratoire:
prefixes: ["J96"]
libelle_famille: "Insuffisance respiratoire"
items:
- categorie: biologie
element: Gaz du sang
match_bio: ["gaz du sang", "gazometrie", "gazométrie", "gds", "pao2", "paco2"]
importance: obligatoire
impact_cpam: "Gazométrie artérielle obligatoire pour confirmer une insuffisance respiratoire"
fractures:
prefixes: ["S02", "S12", "S22", "S32", "S42", "S52", "S62", "S72", "S82", "S92"]
libelle_famille: "Fracture"
items:
- categorie: imagerie
element: Imagerie osseuse
match_imagerie: ["radio", "radiographie", "scanner", "tdm", "irm", "rx"]
importance: obligatoire
impact_cpam: "Imagerie indispensable pour confirmer une fracture"
infection_urinaire:
prefixes: ["N39.0"]
libelle_famille: "Infection urinaire"
items:
- categorie: biologie
element: ECBU
match_bio: ["ecbu", "examen cytobacteriologique", "examen cytobactériologique"]
importance: obligatoire
impact_cpam: "ECBU obligatoire pour documenter une infection urinaire"
fa_flutter:
prefixes: ["I48"]
libelle_famille: "Fibrillation auriculaire / Flutter"
items:
- categorie: biologie
element: ECG
match_bio: ["ecg", "electrocardiogramme", "électrocardiogramme"]
importance: obligatoire
impact_cpam: "ECG obligatoire pour documenter une FA/flutter"
# --- Règles par préfixe CCAM (actes) ---
actes:
chirurgie:
description: "Acte chirurgical nécessitant un CRO"
prefixes: ["H", "J", "K", "L"]
items:
- categorie: document
element: CRO
match_document: ["cro", "compte rendu operatoire", "compte-rendu opératoire"]
importance: obligatoire
impact_cpam: "Compte-rendu opératoire obligatoire pour tout acte chirurgical"

View File

@@ -26,9 +26,9 @@ mutual_exclusions:
incompatibilities:
- pair: ["E66", "E40", "E41", "E42", "E43", "E44", "E45", "E46"]
atih_ref: "Guide Méthodologique MCO : Incompatibilité clinique Obésité / Dénutrition"
message: "Obésité (E66) et Dénutrition/Malnutrition (E40-E46) sont cliniquement incompatibles"
severity: "HARD"
atih_ref: "HAS/FFN nov 2021 : un patient obèse peut être dénutri"
message: "Obésité et Dénutrition coexistent — vérifier critères HAS 2021 (perte de poids, sarcopénie, albumine)"
severity: "MEDIUM"
- pair: ["I10", "I95"]
atih_ref: "Guide Méthodologique MCO : Incohérence Hypertension / Hypotension sur le même séjour"

View File

@@ -1,107 +1,92 @@
version: 1
# Catalogue "socle" de règles.
#
# Objectif : piloter (sans toucher au code) :
# - l'activation/désactivation de règles (vetos + décisions)
# - éventuellement un forçage de sévérité pour un VETO
#
# Important : si une règle n'est pas listée ici, elle est considérée activée.
# (=> comportement historique conservé)
packs:
vetos_core:
enabled: true
rules:
VETO-02:
enabled: true
description: "Code sans preuve exploitable"
description: Code sans preuve exploitable
VETO-03:
enabled: true
description: "Conditionnel / négation / contradictions dans la preuve"
description: Conditionnel / négation / contradictions dans la preuve
VETO-06:
enabled: true
description: "DP dupliqué dans les DAS"
description: DP dupliqué dans les DAS
VETO-07:
enabled: true
description: "Doublons DAS"
description: Doublons DAS
VETO-09:
enabled: true
description: "Contradiction biologique (plaquettes/créat)"
# force_severity: "HARD" # Optionnel : forcer la sévérité globale
description: Contradiction biologique (plaquettes/créat)
VETO-12:
enabled: true
description: "Sur-confiance (high sans preuve)"
description: Sur-confiance (high sans preuve)
VETO-15:
enabled: true
description: "Preuve issue d'un score/test (risque de sur-codage)"
description: Preuve issue d'un score/test (risque de sur-codage)
VETO-16:
enabled: true
description: "Heuristique libellé→code (hors-sujet probable)"
description: Heuristique libellé→code (hors-sujet probable)
VETO-17:
enabled: true
description: "Preuve biologique manquante => NEED_INFO (non bloquant)"
description: Preuve biologique manquante => NEED_INFO (non bloquant)
decisions_core:
enabled: true
rules:
RULE-D50-NEEDS-IRON:
enabled: true
description: "D50 sans preuve martiale => downgrade D64.9 + NEED_INFO"
description: D50 sans preuve martiale => downgrade D64.9 + NEED_INFO
RULE-D69.6-PLT-NORMAL:
enabled: true
description: "D69.6 incompatible avec plaquettes normales => ruled_out (barré)"
description: D69.6 incompatible avec plaquettes normales => ruled_out (barré)
RULE-DAS-TO-DP:
enabled: true
description: "DAS promu en DP si aucun DP extrait — sélection par pertinence/confiance/spécificité"
description: DAS promu en DP si aucun DP extrait — sélection par pertinence/confiance/spécificité
RULE-CPAM-CORRECTION-LOOP:
enabled: true
description: "Boucle de correction quand validation adversariale score ≤ 5/10"
description: Boucle de correction quand validation adversariale score ≤ 5/10
bio_electrolytes:
enabled: true
rules:
RULE-E87.1-NA-NORMAL:
enabled: true
description: "E87.1 suggérée mais Na normal => ruled_out"
description: E87.1 suggérée mais Na normal => ruled_out
RULE-E87.1-MISSING-NA:
enabled: true
description: "E87.1 suggérée mais Na absent => NEED_INFO"
description: E87.1 suggérée mais Na absent => NEED_INFO
RULE-E87.5-K-NORMAL:
enabled: true
description: "E87.5 suggérée mais K normal => ruled_out"
description: E87.5 suggérée mais K normal => ruled_out
RULE-E87.5-MISSING-K:
enabled: true
description: "E87.5 suggérée mais K absent => NEED_INFO"
description: E87.5 suggérée mais K absent => NEED_INFO
RULE-E87.6-K-NORMAL:
enabled: true
description: "E87.6 suggérée mais K normal => ruled_out"
description: E87.6 suggérée mais K normal => ruled_out
RULE-E87.6-MISSING-K:
enabled: true
description: "E87.6 suggérée mais K absent => NEED_INFO"
description: E87.6 suggérée mais K absent => NEED_INFO
atih_core:
enabled: true
rules:
VETO-20:
enabled: true
description: "Z code interdit en DP (sauf whitelist Z09/Z51/Z54/Z75/Z03/Z04/Z38/Z50/Z08)"
description: Z code interdit en DP (sauf whitelist Z09/Z51/Z54/Z75/Z03/Z04/Z38/Z50/Z08)
VETO-21:
enabled: true
description: "Code R (symptôme) en DP → CMD 23, tarification faible"
description: Code R (symptôme) en DP → CMD 23, tarification faible
VETO-22:
enabled: true
description: "Même catégorie CIM-10 3 chars en DP + DAS (redondance)"
description: Même catégorie CIM-10 3 chars en DP + DAS (redondance)
VETO-23:
enabled: true
description: "Exclusions mutuelles (E10/E11 diabète, I10/I11-I13 HTA)"
description: Exclusions mutuelles (E10/E11 diabète, I10/I11-I13 HTA)
VETO-24:
enabled: true
description: "Lésion traumatique (S/T) sans cause externe (V/W/X/Y)"
description: Lésion traumatique (S/T) sans cause externe (V/W/X/Y)
placeholders_future:
enabled: false
rules:
RULE-PDF-PROTECTED-NEED_INFO:
enabled: false
description: "PDF protégé => NEED_INFO (à implémenter si besoin)"
description: PDF protégé => NEED_INFO (à implémenter si besoin)

Binary file not shown.

View File

@@ -1,738 +0,0 @@
#!/usr/bin/env python3
"""
extract_t2a_llm.py — Extracteur T2A généraliste via OCR + LLM (Ollama)
Entrée : PDF (scanné ou natif) de document T2A (décision UCR, notification CPAM, rapport ARS…)
Sortie : Fichier Excel (.xlsx) avec les données structurées
Architecture :
PDF → OCR/texte natif → Détection type (1 appel LLM) → Extraction bloc par bloc (N appels LLM) → Excel
Usage :
python extract_t2a_llm.py FICHIER.pdf [--model gemma3:27b-it-qat] [--output out.xlsx] [--verbose]
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import time
from pathlib import Path
import pymupdf
import requests
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
# ---------------------------------------------------------------------------
# 0. Normalisation texte OCR
# ---------------------------------------------------------------------------
def normalize_text(text: str) -> str:
"""Normalise les apostrophes, guillemets et espaces issus de l'OCR."""
text = text.replace("\u2018", "'").replace("\u2019", "'")
text = text.replace("\u201C", '"').replace("\u201D", '"')
text = text.replace("\u00AB", '"').replace("\u00BB", '"')
text = text.replace("''", "'")
text = text.replace("\u00A0", " ").replace("\u202F", " ")
text = re.sub(r"\bF'UCR\b", "l'UCR", text)
text = re.sub(r"\bl''UCR\b", "l'UCR", text)
return text
# ---------------------------------------------------------------------------
# 1. OCR / Extraction texte (docTR — deep learning, GPU)
# ---------------------------------------------------------------------------
_doctr_model = None
def _get_doctr_model():
"""Lazy-init du modèle docTR (chargé une seule fois, GPU si VRAM libre, sinon CPU)."""
global _doctr_model
if _doctr_model is not None:
return _doctr_model
from doctr.models import ocr_predictor
print(" Chargement du modèle docTR (première utilisation)...")
t0 = time.time()
_doctr_model = ocr_predictor(
det_arch="db_resnet50",
reco_arch="crnn_vgg16_bn",
pretrained=True,
)
# Déplacer sur GPU si disponible et assez de VRAM libre
try:
import torch
if torch.cuda.is_available():
free_vram = torch.cuda.mem_get_info()[0] / (1024 ** 3)
if free_vram > 1.0:
try:
_doctr_model = _doctr_model.cuda()
print(f" docTR sur GPU ({torch.cuda.get_device_name(0)}, "
f"{free_vram:.1f} Go libres) — {time.time() - t0:.1f}s")
except torch.cuda.OutOfMemoryError:
_doctr_model = _doctr_model.cpu()
torch.cuda.empty_cache()
print(f" GPU VRAM insuffisante, docTR sur CPU — {time.time() - t0:.1f}s")
else:
print(f" GPU VRAM trop basse ({free_vram:.1f} Go libres, Ollama ?), "
f"docTR sur CPU — {time.time() - t0:.1f}s")
else:
print(f" docTR sur CPU — {time.time() - t0:.1f}s")
except ImportError:
print(f" docTR sur CPU — {time.time() - t0:.1f}s")
return _doctr_model
def ocr_pdf(pdf_path: str, dpi: int = 300) -> str:
"""Extrait le texte du PDF : texte natif si disponible, sinon OCR docTR (GPU)."""
doc = pymupdf.open(pdf_path)
total = len(doc)
# Détection : texte natif vs scanné (sur la première page)
first_page_text = doc[0].get_text() if total > 0 else ""
is_native = len(first_page_text.strip()) > 100
if is_native:
print(" Mode : extraction texte natif (pymupdf)")
full_text = []
for i, page in enumerate(doc):
print(f" Extraction page {i+1}/{total}...", end="\r")
full_text.append(page.get_text())
print(f" Extraction terminée : {total} pages. ")
return normalize_text("\n\n".join(full_text))
# OCR docTR
print(" Mode : OCR docTR (deep learning, GPU)")
from doctr.io import DocumentFile
model = _get_doctr_model()
print(f" Lecture du PDF ({total} pages)...")
doc_pages = DocumentFile.from_pdf(pdf_path)
print(f" OCR en cours sur {len(doc_pages)} pages...")
t0 = time.time()
result = model(doc_pages)
elapsed = time.time() - t0
print(f" OCR terminé : {total} pages en {elapsed:.1f}s "
f"({elapsed/total:.1f}s/page)")
full_text = result.render()
return normalize_text(full_text)
# ---------------------------------------------------------------------------
# 2. Client Ollama
# ---------------------------------------------------------------------------
NO_FORMAT_JSON_PREFIXES = ("qwen3", "qwen2.5")
OLLAMA_URL = "http://localhost:11434"
def parse_json_response(raw: str) -> dict | list | None:
"""Parse une réponse JSON, en gérant les blocs markdown et le texte parasite."""
text = raw.strip()
# Supprimer les blocs <think>...</think> (Qwen3)
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
# Supprimer les blocs markdown ```json ... ```
if text.startswith("```"):
first_nl = text.find("\n")
if first_nl != -1:
text = text[first_nl + 1:]
if text.rstrip().endswith("```"):
text = text.rstrip()[:-3]
text = text.strip()
# Tentative directe
try:
return json.loads(text)
except json.JSONDecodeError:
pass
# Extraire le premier objet ou tableau JSON
for start_char, end_char in [("{", "}"), ("[", "]")]:
start = text.find(start_char)
if start == -1:
continue
depth = 0
for i in range(start, len(text)):
if text[i] == start_char:
depth += 1
elif text[i] == end_char:
depth -= 1
if depth == 0:
try:
return json.loads(text[start:i + 1])
except json.JSONDecodeError:
break
return None
def call_ollama(
prompt: str,
model: str,
temperature: float = 0.1,
max_tokens: int = 4000,
timeout: int = 120,
verbose: bool = False,
) -> dict | list | None:
"""Appelle Ollama. Utilise l'API chat avec think=false pour Qwen3."""
is_qwen = any(model.startswith(p) for p in NO_FORMAT_JSON_PREFIXES)
if is_qwen:
# API chat + think:false pour Qwen3 (pas de format JSON natif)
endpoint = f"{OLLAMA_URL}/api/chat"
body = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"think": False,
"options": {
"temperature": temperature,
"num_predict": max_tokens,
},
}
else:
# API generate + format JSON natif pour les autres modèles
endpoint = f"{OLLAMA_URL}/api/generate"
body = {
"model": model,
"prompt": prompt,
"stream": False,
"format": "json",
"options": {
"temperature": temperature,
"num_predict": max_tokens,
},
}
if verbose:
print(f"\n--- PROMPT ({model}) ---")
print(prompt[:500] + ("..." if len(prompt) > 500 else ""))
print("--- FIN PROMPT ---\n")
for attempt in range(2):
try:
t0 = time.time()
response = requests.post(endpoint, json=body, timeout=timeout)
elapsed = time.time() - t0
response.raise_for_status()
data = response.json()
# Extraire le texte de la réponse selon l'API utilisée
if is_qwen:
raw = data.get("message", {}).get("content", "")
else:
raw = data.get("response", "")
if verbose:
print(f"--- RÉPONSE ({elapsed:.1f}s) ---")
print(raw[:500] + ("..." if len(raw) > 500 else ""))
print("--- FIN RÉPONSE ---\n")
result = parse_json_response(raw)
if result is not None:
return result
if attempt == 0:
print(f" [warn] JSON invalide, retry... (raw: {raw[:100]})")
except requests.ConnectionError:
print("[ERREUR] Ollama non disponible sur localhost:11434")
sys.exit(1)
except requests.Timeout:
print(f" [warn] Timeout ({timeout}s) — tentative {attempt + 1}/2")
if attempt == 1:
return None
except requests.RequestException as e:
print(f" [warn] Erreur requête : {e}")
return None
return None
# ---------------------------------------------------------------------------
# 3. Phase 1 — Détection du type de document
# ---------------------------------------------------------------------------
PROMPT_PHASE1 = """\
Tu es un expert en codage PMSI et contrôle T2A. Analyse le début de ce document et identifie sa structure.
TEXTE (début du document) :
---
{text_preview}
---
Réponds UNIQUEMENT en JSON avec ces champs :
{{
"type_document": "decision_ucr | notification_cpam | rapport_controle | autre",
"organisme": "nom de l'organisme (CPAM, UCR, ARS...)",
"date_document": "date au format YYYY-MM-DD si trouvée, sinon vide",
"objet": "résumé en une phrase de l'objet du document",
"separateur_blocs": "regex Python pour séparer les dossiers individuels (ex: OGC \\\\d+ :)",
"colonnes_detectees": ["liste des champs/colonnes détectés dans la structure"]
}}
IMPORTANT :
- Le separateur_blocs doit être un regex Python valide
- Il doit capturer le motif qui sépare chaque dossier/cas individuel
- Si c'est un document UCR, le séparateur est typiquement "OGC \\\\d+ :"
- Si tu ne trouves pas de séparateur clair, mets une chaîne vide ""
"""
def detect_document_type(full_text: str, model: str, timeout: int, verbose: bool) -> dict:
"""Phase 1 : détection du type de document via LLM."""
preview = full_text[:3000]
prompt = PROMPT_PHASE1.format(text_preview=preview)
result = call_ollama(prompt, model=model, timeout=timeout, verbose=verbose)
if result is None:
print(" [warn] Phase 1 : détection échouée, utilisation des valeurs par défaut")
return {
"type_document": "autre",
"organisme": "",
"date_document": "",
"objet": "",
"separateur_blocs": "",
"colonnes_detectees": [],
}
return result
# ---------------------------------------------------------------------------
# 4. Découpage en blocs
# ---------------------------------------------------------------------------
def split_into_blocks(full_text: str, separator_pattern: str) -> list[str]:
"""Découpe le texte en blocs logiques (dossiers individuels)."""
blocks = []
# Tentative avec le séparateur détecté par le LLM
if separator_pattern:
try:
regex = re.compile(separator_pattern, re.MULTILINE | re.IGNORECASE)
parts = regex.split(full_text)
# Recombiner : le séparateur fait partie du bloc suivant
matches = list(regex.finditer(full_text))
if len(matches) >= 3:
for i, match in enumerate(matches):
start = match.start()
end = matches[i + 1].start() if i + 1 < len(matches) else len(full_text)
block = full_text[start:end].strip()
if block:
blocks.append(block)
print(f" Découpage par séparateur : {len(blocks)} blocs trouvés")
return blocks
else:
print(f" [warn] Séparateur '{separator_pattern}' → seulement {len(matches)} blocs, fallback")
except re.error as e:
print(f" [warn] Regex invalide '{separator_pattern}' : {e}, fallback")
# Fallback : découpage par taille (~6000 chars, chevauchement 500)
chunk_size = 6000
overlap = 500
text_len = len(full_text)
if text_len <= chunk_size:
return [full_text]
pos = 0
while pos < text_len:
end = min(pos + chunk_size, text_len)
# Essayer de couper à une fin de ligne
if end < text_len:
newline_pos = full_text.rfind("\n", pos + chunk_size - 200, end + 200)
if newline_pos > pos:
end = newline_pos
blocks.append(full_text[pos:end].strip())
pos = end - overlap if end < text_len else text_len
print(f" Découpage par taille : {len(blocks)} blocs ({chunk_size} chars, chevauchement {overlap})")
return blocks
# ---------------------------------------------------------------------------
# 5. Phase 2 — Extraction bloc par bloc
# ---------------------------------------------------------------------------
SCHEMA_FIELDS = """\
Champs à extraire (JSON) — remplis chaque champ ou laisse une chaîne vide "" si non trouvé :
- "champ": numéro de champ (entier, 0 si non trouvé)
- "ogc": numéro OGC / numéro de dossier (entier, 0 si non trouvé)
- "type_desaccord": type de désaccord — "DP", "DAS", "DP + DAS", ou ""
- "code_etablissement": code(s) CIM-10 de l'établissement (ex: "G40.0 + F10.2")
- "libelle_etablissement": libellé(s) correspondant aux codes établissement
- "code_controleurs": code(s) CIM-10 des contrôleurs (ou "non repris")
- "libelle_controleurs": libellé(s) correspondant aux codes contrôleurs
- "codes_retenus_final": code(s) finalement retenus par l'UCR/la décision
- "decision": classification — "Favorable établissement", "Défavorable établissement", "Mixte", ou "Indéterminé"
* "Favorable établissement" = la décision retient l'avis/le codage de l'établissement
* "Défavorable établissement" = la décision confirme l'avis des contrôleurs
* "Mixte" = partiellement favorable et partiellement défavorable
* "Indéterminé" = impossible à classifier clairement
- "texte_decision_complet": texte intégral de la décision/conclusion
- "resume_motif": résumé en 1-2 phrases du motif de la décision
- "regles_citees": règles de codage citées (ex: "T3, T7")
- "references_guide": références documentaires (guide méthodologique, fascicules ATIH, avis Agora…)
- "ghm_mentionne": tous les GHM mentionnés (ex: "05M09 / 05M092")
- "ghs_mentionne": tous les GHS mentionnés
- "ghm_final": le GHM final retenu
- "ghs_final": le GHS final retenu
- "impact_groupage": impact sur le groupage — "Mieux valorisé", "Pas de changement", ou ""
"""
PROMPT_PHASE2 = """\
Tu es un expert en codage PMSI et contrôle T2A.
CONTEXTE DOCUMENT :
- Type : {type_document}
- Organisme : {organisme}
- Objet : {objet}
BLOC DE TEXTE À ANALYSER :
---
{block_text}
---
CONSIGNES :
1. Extrais les informations de chaque dossier/cas présent dans ce bloc.
2. Si le bloc contient UN SEUL dossier, retourne un objet JSON.
3. Si le bloc contient PLUSIEURS dossiers, retourne une LISTE d'objets JSON.
4. Si le bloc ne contient aucun dossier exploitable (en-tête, pied de page, texte administratif sans cas individuel), retourne : {{"skip": true}}
{schema}
IMPORTANT :
- Sois précis sur les codes CIM-10 (format X00.0)
- Pour la décision, analyse attentivement le texte : "retient l'avis de l'établissement" = Favorable, "confirme l'avis des contrôleurs" = Défavorable
- Ne laisse aucun champ sans clé, utilise "" pour les valeurs inconnues
- Retourne UNIQUEMENT du JSON valide, sans texte avant ou après
"""
def extract_block(
block_text: str,
doc_info: dict,
model: str,
timeout: int,
verbose: bool,
) -> list[dict]:
"""Extrait les données d'un bloc via LLM. Retourne une liste de dossiers."""
prompt = PROMPT_PHASE2.format(
type_document=doc_info.get("type_document", "autre"),
organisme=doc_info.get("organisme", ""),
objet=doc_info.get("objet", ""),
block_text=block_text[:8000], # Limiter la taille
schema=SCHEMA_FIELDS,
)
result = call_ollama(prompt, model=model, max_tokens=4000, timeout=timeout, verbose=verbose)
if result is None:
return []
# Skip
if isinstance(result, dict) and result.get("skip"):
return []
# Normaliser en liste
if isinstance(result, dict):
items = [result]
elif isinstance(result, list):
items = [r for r in result if isinstance(r, dict) and not r.get("skip")]
else:
return []
return items
# ---------------------------------------------------------------------------
# 6. Fusion et dédoublonnage
# ---------------------------------------------------------------------------
# Mapping clés LLM (snake_case) → clés Excel (TitleCase)
KEY_MAP = {
"champ": "Champ",
"ogc": "OGC",
"type_desaccord": "Type_desaccord",
"code_etablissement": "Code_etablissement",
"libelle_etablissement": "Libelle_etablissement",
"code_controleurs": "Code_controleurs",
"libelle_controleurs": "Libelle_controleurs",
"codes_retenus_final": "Codes_retenus_final",
"decision": "Decision",
"texte_decision_complet": "Texte_decision_complet",
"resume_motif": "Resume_motif",
"regles_citees": "Regles_citees",
"references_guide": "References_guide",
"ghm_mentionne": "GHM_mentionne",
"ghs_mentionne": "GHS_mentionne",
"ghm_final": "GHM_final",
"ghs_final": "GHS_final",
"impact_groupage": "Impact_groupage",
}
def normalize_row(raw: dict) -> dict:
"""Convertit les clés LLM en clés Excel et normalise les types."""
row = {}
for llm_key, excel_key in KEY_MAP.items():
val = raw.get(llm_key, raw.get(excel_key, ""))
# Convertir en int pour Champ et OGC
if excel_key in ("Champ", "OGC"):
try:
val = int(val) if val else 0
except (ValueError, TypeError):
val = 0
elif not isinstance(val, str):
val = str(val) if val is not None else ""
row[excel_key] = val
return row
def merge_and_deduplicate(all_items: list[dict]) -> list[dict]:
"""Fusionne, déduplique par OGC, et trie les résultats."""
rows = [normalize_row(item) for item in all_items]
# Filtrer les lignes sans contenu utile
rows = [r for r in rows if r["OGC"] > 0 or r["Code_etablissement"] or r["Decision"]]
# Dédoublonnage par OGC (garder la version la plus complète)
seen: dict[int, dict] = {}
deduped: list[dict] = []
for r in rows:
key = r["OGC"]
if key == 0:
deduped.append(r)
continue
if key in seen:
old = seen[key]
old_score = sum(1 for v in old.values() if v and v != 0)
new_score = sum(1 for v in r.values() if v and v != 0)
if new_score > old_score:
deduped = [x for x in deduped if x["OGC"] != key]
deduped.append(r)
seen[key] = r
else:
seen[key] = r
deduped.append(r)
deduped.sort(key=lambda r: (r["Champ"], r["OGC"]))
return deduped
# ---------------------------------------------------------------------------
# 7. Export Excel
# ---------------------------------------------------------------------------
HEADERS = [
"Champ", "OGC", "Type_desaccord",
"Code_etablissement", "Libelle_etablissement",
"Code_controleurs", "Libelle_controleurs",
"Codes_retenus_final",
"Decision", "Texte_decision_complet", "Resume_motif",
"Regles_citees", "References_guide",
"GHM_mentionne", "GHS_mentionne", "GHM_final", "GHS_final",
"Impact_groupage",
]
HEADER_LABELS = [
"Champ", "N° OGC", "Type désaccord",
"Code(s) Établissement", "Libellé Établissement",
"Code(s) Contrôleurs", "Libellé Contrôleurs",
"Code(s) retenus (final)",
"Décision UCR", "Texte décision complet", "Résumé du motif",
"Règles codage citées", "Références (guide, fascicules, avis)",
"GHM mentionné(s)", "GHS mentionné(s)", "GHM final", "GHS final",
"Impact groupage",
]
def write_excel(rows: list[dict], output_path: str):
"""Écrit les résultats dans un fichier Excel (feuille unique)."""
wb = Workbook()
ws = wb.active
ws.title = "Décisions UCR"
# Styles
header_font = Font(bold=True, color="FFFFFF", size=11)
header_fill = PatternFill(start_color="2F5496", end_color="2F5496", fill_type="solid")
header_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
thin_border = Border(
left=Side(style="thin"), right=Side(style="thin"),
top=Side(style="thin"), bottom=Side(style="thin"),
)
fav_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
defav_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
mixte_fill = PatternFill(start_color="FFEB9C", end_color="FFEB9C", fill_type="solid")
# En-têtes
for col, label in enumerate(HEADER_LABELS, 1):
cell = ws.cell(row=1, column=col, value=label)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_align
cell.border = thin_border
# Données
for row_idx, data in enumerate(rows, 2):
for col_idx, key in enumerate(HEADERS, 1):
val = data.get(key, "")
cell = ws.cell(row=row_idx, column=col_idx, value=val)
cell.border = thin_border
cell.alignment = Alignment(vertical="top", wrap_text=True)
# Colorer la colonne Décision
dec_col = HEADERS.index("Decision") + 1
decision_cell = ws.cell(row=row_idx, column=dec_col)
dv = str(decision_cell.value or "")
if "Favorable" in dv and "Défavorable" not in dv:
decision_cell.fill = fav_fill
elif "Défavorable" in dv:
decision_cell.fill = defav_fill
elif "Mixte" in dv:
decision_cell.fill = mixte_fill
# Largeurs de colonnes
col_widths = {
"Champ": 8, "OGC": 8, "Type_desaccord": 14,
"Code_etablissement": 22, "Libelle_etablissement": 40,
"Code_controleurs": 22, "Libelle_controleurs": 40,
"Codes_retenus_final": 22,
"Decision": 24, "Texte_decision_complet": 80,
"Resume_motif": 60,
"Regles_citees": 16, "References_guide": 50,
"GHM_mentionne": 16, "GHS_mentionne": 16,
"GHM_final": 12, "GHS_final": 10,
"Impact_groupage": 20,
}
for i, key in enumerate(HEADERS, 1):
ws.column_dimensions[ws.cell(row=1, column=i).column_letter].width = col_widths.get(key, 15)
# Filtre automatique + freeze
last_col_letter = ws.cell(row=1, column=len(HEADERS)).column_letter
ws.auto_filter.ref = f"A1:{last_col_letter}{len(rows)+1}"
ws.freeze_panes = "A2"
wb.save(output_path)
print(f"Excel enregistré : {output_path}")
# ---------------------------------------------------------------------------
# 8. CLI / Main
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Extracteur T2A généraliste via OCR + LLM (Ollama)",
)
parser.add_argument("pdf", help="Fichier PDF à traiter")
parser.add_argument("--model", default="gemma3:27b-it-qat",
help="Modèle Ollama (défaut: gemma3:27b-it-qat)")
parser.add_argument("--timeout", type=int, default=120,
help="Timeout par appel LLM en secondes (défaut: 120)")
parser.add_argument("--output", default=None,
help="Fichier Excel de sortie (défaut: <nom>_llm.xlsx)")
parser.add_argument("--dpi", type=int, default=300,
help="Résolution OCR (défaut: 300)")
parser.add_argument("--no-cache", action="store_true",
help="Désactiver le cache texte OCR")
parser.add_argument("--verbose", action="store_true",
help="Afficher les prompts/réponses LLM")
args = parser.parse_args()
pdf_path = args.pdf
if not Path(pdf_path).exists():
print(f"[ERREUR] Fichier non trouvé : {pdf_path}")
sys.exit(1)
output_path = args.output or str(Path(pdf_path).with_name(
Path(pdf_path).stem + "_llm.xlsx"
))
print(f"Fichier PDF : {pdf_path}")
print(f"Modèle LLM : {args.model}")
print(f"Sortie Excel : {output_path}")
print()
# --- Étape 1 : OCR ---
txt_cache = Path(pdf_path).with_suffix(".txt")
if txt_cache.exists() and not args.no_cache:
print("Étape 1/4 : Chargement du texte depuis le cache...")
full_text = txt_cache.read_text(encoding="utf-8")
full_text = normalize_text(full_text)
print(f" {len(full_text)} caractères chargés depuis {txt_cache}")
else:
print("Étape 1/4 : OCR du document...")
full_text = ocr_pdf(pdf_path, dpi=args.dpi)
if not args.no_cache:
txt_cache.write_text(full_text, encoding="utf-8")
print(f" Cache texte sauvegardé : {txt_cache}")
print(f" Longueur du texte : {len(full_text)} caractères")
print()
# --- Étape 2 : Détection du type de document ---
print("Étape 2/4 : Détection du type de document...")
t0 = time.time()
doc_info = detect_document_type(full_text, model=args.model, timeout=args.timeout, verbose=args.verbose)
print(f" Type : {doc_info.get('type_document', '?')}")
print(f" Organisme : {doc_info.get('organisme', '?')}")
print(f" Objet : {doc_info.get('objet', '?')}")
print(f" Séparateur: {doc_info.get('separateur_blocs', '(aucun)')}")
print(f" Colonnes : {doc_info.get('colonnes_detectees', [])}")
print(f" ({time.time() - t0:.1f}s)")
print()
# --- Étape 3 : Découpage et extraction ---
print("Étape 3/4 : Découpage en blocs et extraction LLM...")
separator = doc_info.get("separateur_blocs", "")
blocks = split_into_blocks(full_text, separator)
print(f" {len(blocks)} blocs à traiter")
all_items = []
t0 = time.time()
for i, block in enumerate(blocks):
print(f" Bloc {i+1}/{len(blocks)}...", end="\r")
items = extract_block(block, doc_info, model=args.model, timeout=args.timeout, verbose=args.verbose)
all_items.extend(items)
# Progression
elapsed = time.time() - t0
avg = elapsed / (i + 1)
remaining = avg * (len(blocks) - i - 1)
print(f" Bloc {i+1}/{len(blocks)}{len(items)} dossier(s) "
f"[{elapsed:.0f}s écoulé, ~{remaining:.0f}s restant] ")
total_elapsed = time.time() - t0
print(f" Extraction terminée : {len(all_items)} dossiers bruts en {total_elapsed:.0f}s")
print()
# --- Étape 4 : Fusion et export ---
print("Étape 4/4 : Fusion, dédoublonnage et export Excel...")
rows = merge_and_deduplicate(all_items)
print(f" {len(rows)} dossiers après dédoublonnage")
# Statistiques
fav = sum(1 for r in rows if "Favorable" in r.get("Decision", "") and "Défavorable" not in r.get("Decision", ""))
defav = sum(1 for r in rows if "Défavorable" in r.get("Decision", ""))
mixte = sum(1 for r in rows if "Mixte" in r.get("Decision", ""))
indet = sum(1 for r in rows if r.get("Decision", "") in ("Indéterminé", ""))
print(f" Favorable établissement : {fav}")
print(f" Défavorable établissement : {defav}")
print(f" Mixte : {mixte}")
print(f" Indéterminé : {indet}")
write_excel(rows, output_path)
print()
print("Terminé.")
if __name__ == "__main__":
main()

View File

@@ -1,690 +0,0 @@
#!/usr/bin/env python3
"""
parse_decision_ucr.py — Extraction des décisions UCR depuis un PDF scanné (contrôle T2A)
Entrée : PDF scanné de décision UCR (CPAM / Assurance Maladie)
Sortie : Fichier Excel (.xlsx) avec une feuille unique
Colonnes extraites (enrichies pour analyse IA) :
Champ, OGC, Type_desaccord,
Code_etablissement, Libelle_etablissement,
Code_controleurs, Libelle_controleurs,
Codes_retenus_final,
Decision, Texte_decision_complet, Resume_motif,
Regles_citees, References_guide,
GHM_mentionne, GHS_mentionne, GHM_final, GHS_final,
Impact_groupage
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import pymupdf
import pytesseract
from PIL import Image
import io
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
import unicodedata
# ---------------------------------------------------------------------------
# 0. Normalisation texte OCR
# ---------------------------------------------------------------------------
def normalize_text(text: str) -> str:
"""Normalise les apostrophes, guillemets et espaces issus de l'OCR."""
text = text.replace("\u2018", "'").replace("\u2019", "'")
text = text.replace("\u201C", '"').replace("\u201D", '"')
text = text.replace("\u00AB", '"').replace("\u00BB", '"')
text = text.replace("''", "'")
text = text.replace("\u00A0", " ").replace("\u202F", " ")
# Erreurs OCR courantes
text = re.sub(r"\bF'UCR\b", "l'UCR", text)
text = re.sub(r"\bl''UCR\b", "l'UCR", text)
return text
# ---------------------------------------------------------------------------
# 1. OCR
# ---------------------------------------------------------------------------
def ocr_pdf(pdf_path: str, dpi: int = 300) -> str:
"""Extrait le texte de toutes les pages du PDF via Tesseract OCR."""
doc = pymupdf.open(pdf_path)
full_text = []
total = len(doc)
for i, page in enumerate(doc):
print(f" OCR page {i+1}/{total}...", end="\r")
mat = pymupdf.Matrix(dpi / 72, dpi / 72)
pix = page.get_pixmap(matrix=mat)
img = Image.open(io.BytesIO(pix.tobytes("png")))
text = pytesseract.image_to_string(img, lang="fra")
full_text.append(text)
print(f" OCR terminé : {total} pages. ")
return normalize_text("\n\n".join(full_text))
# ---------------------------------------------------------------------------
# 2. Parsing — Regex
# ---------------------------------------------------------------------------
RE_CHAMP = re.compile(
r"Champ\s*(?:n°\s*)?(\d+)\s*[:\-—]?\s*(?:Séjours|:)",
re.IGNORECASE,
)
RE_OGC_HEADER = re.compile(
r"(?:^|\n)\s*OGC\s+(\d+)\s*:",
re.MULTILINE,
)
RE_TYPE_DESACCORD = re.compile(
r"(?:désaccord|discussion)\s+porte\s+(?:sur\s+)?(?:le\s+|les\s+)?(DP\s+et\s+(?:le\s+)?DAS|DP\s+et\s+DAS|DP|DAS)",
re.IGNORECASE,
)
RE_CIM10 = re.compile(r"\b([A-Z]\d{2}(?:\.\d{1,2})?)\b")
RE_CODAGE_ETS = re.compile(
r"Codage\s+[ée]tablissement\s*:\s*(.*?)(?=Codage\s+contr[ôo]leurs)",
re.IGNORECASE | re.DOTALL,
)
RE_CODAGE_CTRL = re.compile(
r"Codage\s+contr[ôo]leurs\s*:\s*(.*?)(?=D[EÉ]C[I1]?SION\s+UCR|PROPOSITION\s+UCR)",
re.IGNORECASE | re.DOTALL,
)
RE_DECISION = re.compile(
r"(?:D[EÉ]C[I1]?SION|PROPOSITION)\s+UCR\s*:?\s*(.*)",
re.IGNORECASE | re.DOTALL,
)
# --- Classification ---
RE_FAVORABLE = re.compile(
r"(?:"
r"retient\s+(?:la\s+demande|le\s+codage|l'avis)\s+(?:de\s+)?l'[ée]tablissement"
r"|retient\s+en\s+D[PA]S\s+le\s+code"
r"|retient\s+le\s+codage\s+du\s+DP\s+de\s+l'[ée]tablissement"
r"|l'UCR\s+retient\s+l'avis\s+de\s+l'[ée]tablissement"
r"|confirme\s+l'avis\s+(?:de\s+)?l'[ée]tablissement"
r")",
re.IGNORECASE,
)
RE_DEFAVORABLE = re.compile(
r"confirme\s+l'avis\s+des\s+(?:m[ée]decins\s+)?contr[oô]leurs",
re.IGNORECASE,
)
RE_UCR_RETIENT = re.compile(r"l'UCR\s+retient\b", re.IGNORECASE)
RE_UCR_PROPOSE = re.compile(r"l'UCR\s+propose\b", re.IGNORECASE)
RE_NE_RETIENT_PAS = re.compile(r"ne\s+retient\s+pas", re.IGNORECASE)
# --- GHM / GHS ---
RE_GHM = re.compile(r"GHM\s+([A-Z0-9]{5,7})", re.IGNORECASE)
RE_GHS = re.compile(r"GHS\s+(\d{3,5})", re.IGNORECASE)
RE_MIEUX_VALORISE = re.compile(r"mieux\s+valoris[ée]", re.IGNORECASE)
RE_PAS_MODIFIE = re.compile(
r"(?:ne\s+modifie\s+pas|ne\s+change(?:nt)?\s+pas|pas\s+de\s+changement|reste\s+group[ée])",
re.IGNORECASE,
)
# --- Règles et références ---
# Pages du guide méthodologique
RE_GUIDE_PAGE = re.compile(
r"(?:guide\s+m[ée]thodologique|guide)\s*(?:p\.?|page)\s*(\d{1,3})",
re.IGNORECASE,
)
RE_PAGE_GUIDE = re.compile(
r"(?:p\.?|page)\s*(\d{1,3})\s+du\s+guide",
re.IGNORECASE,
)
# Règles T (T3, T7, etc.)
RE_REGLE_T = re.compile(
r"r[èe]gle\s+(T\d+)",
re.IGNORECASE,
)
# Fascicules ATIH
RE_FASCICULE = re.compile(
r"fascicule\s+(?:ATIH\s+)?(?:de\s+codage\s+)?(?:PMSI\s+)?(?:n°\s*)?(\d{1,2})?\s*(?:[-]\s*)?([A-ZÀ-Üa-zà-ü\s]+?)(?:\s+(?:de\s+)?(\d{4}))?(?:\s*(?:,\s*)?(?:p\.?\s*|page\s*)(\d+))?",
re.IGNORECASE,
)
# Avis Agora
RE_AVIS_AGORA = re.compile(
r"avis\s+(?:agora|AGORA)\s*(?:n°\s*)?(\d+)",
re.IGNORECASE,
)
# Consignes de codage avec page
RE_CONSIGNES_CODAGE = re.compile(
r"consignes?\s+de\s+codage\s*(?:p\.?\s*|page\s*)(\d+)",
re.IGNORECASE,
)
# Codage retenu / DP retenu / DAS retenu
RE_CODAGE_RETENU = re.compile(
r"(?:codage\s+retenu|DP\s*(?:retenu|=)|DAS\s*(?:retenu|=)|code\s+retenu|est\s+cod[ée]\s+en|se\s+code)\s*(?:est\s+)?(?::?\s*)([A-Z]\d{2}(?:\.\d{1,2})?)",
re.IGNORECASE,
)
# "est ajouté en DAS" / "ajout du code X"
RE_CODE_AJOUTE = re.compile(
r"(?:est\s+ajout[ée]\s+en\s+D[PA]S|ajout(?:er)?\s+(?:du\s+|en\s+D[PA]S\s+(?:le\s+)?)?(?:code\s+)?)\s*(?::?\s*)([A-Z]\d{2}(?:\.\d{1,2})?)",
re.IGNORECASE,
)
# ---------------------------------------------------------------------------
# 2b. Fonctions d'extraction
# ---------------------------------------------------------------------------
def extract_codes_and_label(text: str) -> tuple[str, str]:
"""Extrait les codes CIM-10 et le libellé depuis un bloc de codage."""
codes = RE_CIM10.findall(text)
labels = re.findall(r'"](.*?)[»"]', text)
code_str = " + ".join(codes) if codes else ""
label_str = " | ".join(labels) if labels else text.strip()[:120]
label_str = re.sub(r"\s+", " ", label_str).strip()
return code_str, label_str
def extract_codes_retenus(decision_text: str) -> str:
"""Extrait les codes finalement retenus par l'UCR."""
codes = set()
for m in RE_CODAGE_RETENU.finditer(decision_text):
codes.add(m.group(1))
for m in RE_CODE_AJOUTE.finditer(decision_text):
codes.add(m.group(1))
return " + ".join(sorted(codes)) if codes else ""
def extract_regles(text: str) -> str:
"""Extrait les règles de codage citées (T3, T7, etc.)."""
regles = set()
for m in RE_REGLE_T.finditer(text):
regles.add(m.group(1).upper())
return ", ".join(sorted(regles)) if regles else ""
def extract_references(text: str) -> str:
"""Extrait toutes les références (guide, fascicules, avis Agora, consignes)."""
refs = []
# Pages du guide méthodologique
pages_guide = set()
for m in RE_GUIDE_PAGE.finditer(text):
pages_guide.add(m.group(1))
for m in RE_PAGE_GUIDE.finditer(text):
pages_guide.add(m.group(1))
if pages_guide:
refs.append("Guide méthodologique p." + ", p.".join(sorted(pages_guide, key=int)))
# Fascicules ATIH
for m in RE_FASCICULE.finditer(text):
num = m.group(1) or ""
sujet = (m.group(2) or "").strip()
annee = m.group(3) or ""
page = m.group(4) or ""
ref = "Fascicule"
if num:
ref += f" {num}"
if sujet:
ref += f" {sujet}"
if annee:
ref += f" ({annee})"
if page:
ref += f" p.{page}"
refs.append(ref.strip())
# Avis Agora
for m in RE_AVIS_AGORA.finditer(text):
refs.append(f"Avis Agora n°{m.group(1)}")
# Consignes de codage
for m in RE_CONSIGNES_CODAGE.finditer(text):
refs.append(f"Consignes de codage p.{m.group(1)}")
# Dédupliquer
seen = set()
unique = []
for r in refs:
r_lower = r.lower()
if r_lower not in seen:
seen.add(r_lower)
unique.append(r)
return " ; ".join(unique) if unique else ""
def extract_ghm_ghs_all(text: str) -> tuple[list[str], list[str]]:
"""Extrait tous les GHM et GHS mentionnés."""
ghms = []
for m in RE_GHM.finditer(text):
v = m.group(1).upper()
if v not in ghms:
ghms.append(v)
ghss = []
for m in RE_GHS.finditer(text):
v = m.group(1)
if v not in ghss:
ghss.append(v)
return ghms, ghss
def classify_decision(decision_text: str) -> str:
"""Classifie la décision : Favorable / Défavorable / Mixte / Indéterminé."""
text = normalize_text(decision_text)
fav = bool(RE_FAVORABLE.search(text))
defav = bool(RE_DEFAVORABLE.search(text))
ucr_retient = bool(RE_UCR_RETIENT.search(text))
ucr_propose = bool(RE_UCR_PROPOSE.search(text))
ne_retient_pas = bool(RE_NE_RETIENT_PAS.search(text))
if ucr_retient and not ne_retient_pas:
fav = True
if ucr_propose and not defav:
fav = True
if (ucr_retient or fav) and defav:
return "Mixte"
if fav and defav:
return "Mixte"
elif fav:
return "Favorable établissement"
elif defav:
return "Défavorable établissement"
else:
return "Indéterminé"
def clean_decision_text(text: str) -> str:
"""Nettoie le texte de décision (supprime artifacts OCR en fin de bloc)."""
# Supprimer les lignes de pied de page UCR
text = re.sub(r"\n\s*(?:UCR\s+NA|CONFIDENTIEL|Page\s+\d+).*$", "", text, flags=re.MULTILINE | re.IGNORECASE)
# Supprimer les artefacts OCR de fin (séquences de caractères isolés)
text = re.sub(r"\n\s*[A-Z]{1,4}\s*(?:—|—|-)\s*[a-zA-Z]{0,3}\s*$", "", text, flags=re.MULTILINE)
text = re.sub(r"\n\s*(?:EE|ESS|2 ae|A D ES|EE nd)\s*$", "", text, flags=re.MULTILINE | re.IGNORECASE)
# Normaliser les espaces
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
# ---------------------------------------------------------------------------
# 2c. Parsing des blocs
# ---------------------------------------------------------------------------
def parse_ogc_block(block_text: str, champ: int, ogc_num: int) -> dict:
"""Parse un bloc OGC et retourne un dictionnaire structuré enrichi."""
result = {
"Champ": champ,
"OGC": ogc_num,
"Type_desaccord": "",
"Code_etablissement": "",
"Libelle_etablissement": "",
"Code_controleurs": "",
"Libelle_controleurs": "",
"Codes_retenus_final": "",
"Decision": "",
"Texte_decision_complet": "",
"Resume_motif": "",
"Regles_citees": "",
"References_guide": "",
"GHM_mentionne": "",
"GHS_mentionne": "",
"GHM_final": "",
"GHS_final": "",
"Impact_groupage": "",
}
# Type de désaccord
m = RE_TYPE_DESACCORD.search(block_text)
if m:
raw = m.group(1).upper().strip()
raw = re.sub(r"\s+", " ", raw)
if "DP" in raw and "DAS" in raw:
result["Type_desaccord"] = "DP + DAS"
elif "DAS" in raw:
result["Type_desaccord"] = "DAS"
elif "DP" in raw:
result["Type_desaccord"] = "DP"
# Codage établissement
m = RE_CODAGE_ETS.search(block_text)
if m:
raw_ets = m.group(1).strip()
result["Code_etablissement"], result["Libelle_etablissement"] = extract_codes_and_label(raw_ets)
# Codage contrôleurs
m = RE_CODAGE_CTRL.search(block_text)
if m:
raw_ctrl = m.group(1).strip()
if re.search(r"non\s+repris", raw_ctrl, re.IGNORECASE):
result["Code_controleurs"] = "non repris"
result["Libelle_controleurs"] = ""
else:
result["Code_controleurs"], result["Libelle_controleurs"] = extract_codes_and_label(raw_ctrl)
# Décision UCR — TEXTE COMPLET
m = RE_DECISION.search(block_text)
if m:
decision_text = m.group(1).strip()
decision_clean = clean_decision_text(decision_text)
result["Decision"] = classify_decision(decision_clean)
result["Texte_decision_complet"] = decision_clean
# Résumé court (première phrase significative)
resume = re.sub(r"\s+", " ", decision_clean)[:300].strip()
# Couper à la dernière phrase complète
last_dot = resume.rfind(".")
if last_dot > 100:
resume = resume[:last_dot + 1]
result["Resume_motif"] = resume
# Codes finalement retenus
result["Codes_retenus_final"] = extract_codes_retenus(decision_clean)
# Règles citées (T3, T7, etc.)
result["Regles_citees"] = extract_regles(block_text)
# Références (guide, fascicules, avis Agora)
result["References_guide"] = extract_references(block_text)
# GHM / GHS — tous ceux mentionnés et le dernier (= final)
ghms, ghss = extract_ghm_ghs_all(block_text)
if ghms:
result["GHM_mentionne"] = " / ".join(ghms)
result["GHM_final"] = ghms[-1] # Le dernier mentionné est souvent le final
if ghss:
result["GHS_mentionne"] = " / ".join(ghss)
result["GHS_final"] = ghss[-1]
# Impact groupage
if RE_MIEUX_VALORISE.search(block_text):
result["Impact_groupage"] = "Mieux valorisé"
elif RE_PAS_MODIFIE.search(block_text):
result["Impact_groupage"] = "Pas de changement"
return result
def parse_grouped_ogcs(text_block: str, champ: int, ogc_nums: list[int]) -> list[dict]:
"""Parse un bloc groupé (ex: OGC 14,19,46,50 traités ensemble)."""
template = parse_ogc_block(text_block, champ, ogc_nums[0])
results = []
for num in ogc_nums:
row = dict(template)
row["OGC"] = num
results.append(row)
return results
def parse_document(full_text: str) -> list[dict]:
"""Parse le texte OCR complet et retourne la liste des dossiers."""
rows = []
champ_positions = [(m.start(), int(m.group(1))) for m in RE_CHAMP.finditer(full_text)]
ogc_positions = [(m.start(), int(m.group(1))) for m in RE_OGC_HEADER.finditer(full_text)]
def get_champ_for_position(pos: int) -> int:
ch = 0
for cp, cn in champ_positions:
if cp <= pos:
ch = cn
else:
break
return ch
# Blocs groupés
RE_GROUPED = re.compile(
r"(?:Concernant|Pour)\s+les\s+OGC\s+([\d,\s]+)",
re.IGNORECASE,
)
grouped_ogcs = set()
for m in RE_GROUPED.finditer(full_text):
nums = [int(n.strip()) for n in m.group(1).split(",") if n.strip().isdigit()]
if len(nums) > 1:
start = m.start()
end = len(full_text)
for op, on in ogc_positions:
if op > start + 50 and on not in nums:
end = op
break
block = full_text[start:end]
champ = get_champ_for_position(start)
group_rows = parse_grouped_ogcs(block, champ, nums)
rows.extend(group_rows)
grouped_ogcs.update(nums)
# OGC individuels
for idx, (pos, ogc_num) in enumerate(ogc_positions):
champ = get_champ_for_position(pos)
end = len(full_text)
for next_pos, _ in ogc_positions[idx + 1:]:
if next_pos > pos + 20:
end = next_pos
break
for cp, _ in champ_positions:
if pos < cp < end:
end = cp
break
block = full_text[pos:end]
row = parse_ogc_block(block, champ, ogc_num)
if ogc_num in grouped_ogcs:
if row["Code_etablissement"] and row["Decision"]:
rows = [r for r in rows if r["OGC"] != ogc_num]
rows.append(row)
else:
if row["Code_etablissement"] or row["Decision"]:
rows.append(row)
rows.sort(key=lambda r: (r["Champ"], r["OGC"]))
# Dédupliquer
seen = {}
deduped = []
for r in rows:
key = r["OGC"]
if key in seen:
old = seen[key]
old_score = sum(1 for v in old.values() if v)
new_score = sum(1 for v in r.values() if v)
if new_score > old_score:
deduped = [x for x in deduped if x["OGC"] != key]
deduped.append(r)
seen[key] = r
else:
seen[key] = r
deduped.append(r)
deduped.sort(key=lambda r: (r["Champ"], r["OGC"]))
return deduped
# ---------------------------------------------------------------------------
# 3. Export Excel
# ---------------------------------------------------------------------------
HEADERS = [
"Champ",
"OGC",
"Type_desaccord",
"Code_etablissement",
"Libelle_etablissement",
"Code_controleurs",
"Libelle_controleurs",
"Codes_retenus_final",
"Decision",
"Texte_decision_complet",
"Resume_motif",
"Regles_citees",
"References_guide",
"GHM_mentionne",
"GHS_mentionne",
"GHM_final",
"GHS_final",
"Impact_groupage",
]
HEADER_LABELS = [
"Champ",
"N° OGC",
"Type désaccord",
"Code(s) Établissement",
"Libellé Établissement",
"Code(s) Contrôleurs",
"Libellé Contrôleurs",
"Code(s) retenus (final)",
"Décision UCR",
"Texte décision complet",
"Résumé du motif",
"Règles codage citées",
"Références (guide, fascicules, avis)",
"GHM mentionné(s)",
"GHS mentionné(s)",
"GHM final",
"GHS final",
"Impact groupage",
]
def write_excel(rows: list[dict], output_path: str):
"""Écrit les résultats dans un fichier Excel (feuille unique)."""
wb = Workbook()
ws = wb.active
ws.title = "Décisions UCR"
# Styles
header_font = Font(bold=True, color="FFFFFF", size=11)
header_fill = PatternFill(start_color="2F5496", end_color="2F5496", fill_type="solid")
header_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
thin_border = Border(
left=Side(style="thin"),
right=Side(style="thin"),
top=Side(style="thin"),
bottom=Side(style="thin"),
)
fav_fill = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid")
defav_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
mixte_fill = PatternFill(start_color="FFEB9C", end_color="FFEB9C", fill_type="solid")
# En-têtes
for col, label in enumerate(HEADER_LABELS, 1):
cell = ws.cell(row=1, column=col, value=label)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_align
cell.border = thin_border
# Données
for row_idx, data in enumerate(rows, 2):
for col_idx, key in enumerate(HEADERS, 1):
val = data.get(key, "")
cell = ws.cell(row=row_idx, column=col_idx, value=val)
cell.border = thin_border
cell.alignment = Alignment(vertical="top", wrap_text=True)
# Colorer la colonne Décision
dec_col = HEADERS.index("Decision") + 1
decision_cell = ws.cell(row=row_idx, column=dec_col)
dv = str(decision_cell.value or "")
if "Favorable" in dv and "Défavorable" not in dv:
decision_cell.fill = fav_fill
elif "Défavorable" in dv:
decision_cell.fill = defav_fill
elif "Mixte" in dv:
decision_cell.fill = mixte_fill
# Largeurs de colonnes
col_widths = {
"Champ": 8, "OGC": 8, "Type_desaccord": 14,
"Code_etablissement": 22, "Libelle_etablissement": 40,
"Code_controleurs": 22, "Libelle_controleurs": 40,
"Codes_retenus_final": 22,
"Decision": 24, "Texte_decision_complet": 80,
"Resume_motif": 60,
"Regles_citees": 16, "References_guide": 50,
"GHM_mentionne": 16, "GHS_mentionne": 16,
"GHM_final": 12, "GHS_final": 10,
"Impact_groupage": 20,
}
for i, key in enumerate(HEADERS, 1):
ws.column_dimensions[ws.cell(row=1, column=i).column_letter].width = col_widths.get(key, 15)
# Filtre automatique
last_col_letter = ws.cell(row=1, column=len(HEADERS)).column_letter
ws.auto_filter.ref = f"A1:{last_col_letter}{len(rows)+1}"
# Figer la première ligne
ws.freeze_panes = "A2"
wb.save(output_path)
print(f"Excel enregistré : {output_path}")
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
if len(sys.argv) < 2:
pdf_path = str(Path(__file__).parent / "SPHO-FINANC26020915121.pdf")
else:
pdf_path = sys.argv[1]
output_path = str(Path(pdf_path).with_suffix(".xlsx"))
print(f"Fichier PDF : {pdf_path}")
print("Étape 1/3 : OCR du document...")
full_text = ocr_pdf(pdf_path)
txt_path = str(Path(pdf_path).with_suffix(".txt"))
Path(txt_path).write_text(full_text, encoding="utf-8")
print(f" Texte brut sauvegardé : {txt_path}")
print("Étape 2/3 : Extraction des décisions...")
rows = parse_document(full_text)
print(f" {len(rows)} dossiers OGC extraits.")
fav = sum(1 for r in rows if "Favorable" in r.get("Decision", "") and "Défavorable" not in r.get("Decision", ""))
defav = sum(1 for r in rows if "Défavorable" in r.get("Decision", ""))
mixte = sum(1 for r in rows if "Mixte" in r.get("Decision", ""))
indet = sum(1 for r in rows if r.get("Decision", "") in ("Indéterminé", ""))
refs_count = sum(1 for r in rows if r.get("References_guide"))
codes_ret = sum(1 for r in rows if r.get("Codes_retenus_final"))
regles = sum(1 for r in rows if r.get("Regles_citees"))
print(f" Favorable établissement : {fav}")
print(f" Défavorable établissement : {defav}")
print(f" Mixte : {mixte}")
print(f" Indéterminé : {indet}")
print(f" Avec références citées : {refs_count}")
print(f" Avec codes retenus : {codes_ret}")
print(f" Avec règles T : {regles}")
print("Étape 3/3 : Génération du fichier Excel...")
write_excel(rows, output_path)
print("Terminé.")
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

View File

@@ -1 +0,0 @@
test content

Some files were not shown because too many files have changed in this diff Show More