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>
This commit is contained in:
dom
2026-03-07 16:48:10 +01:00
parent 2578afb6ff
commit 4e2b4bd946
210 changed files with 6939 additions and 22104 deletions

View File

@@ -568,6 +568,39 @@ def _assess_dossier_strength(dossier: DossierMedical) -> dict:
}
def _build_strategie_type(controle: ControleCPAM) -> str:
"""Construit le bloc de stratégie conditionnel selon le type de désaccord."""
td = controle.type_desaccord or "DP"
blocs: dict[str, str] = {
"DP": (
"STRATÉGIE DE CONTESTATION — TYPE : DP (Diagnostic Principal)\n"
"Démontrer que le DP retenu par l'UCR ne correspond pas au motif réel "
"d'hospitalisation. S'appuyer sur le CRH, les règles D1 (symptôme si cause "
"non identifiée) et D2 (cause si identifiée). Le DP doit refléter le diagnostic "
"ayant consommé le plus de ressources pendant le séjour."
),
"DAS": (
"STRATÉGIE DE CONTESTATION — TYPE : DAS (Diagnostics Associés)\n"
"Prouver que la comorbidité a bien été prise en charge pendant le séjour : "
"prescription active, acte spécifique, surveillance documentée, ou allongement "
"de durée. Chaque DAS doit mobiliser des ressources supplémentaires documentées."
),
"Actes": (
"STRATÉGIE DE CONTESTATION — TYPE : Actes CCAM\n"
"Vérifier le code CCAM exact, la date de réalisation, et la concordance avec "
"le compte-rendu opératoire. S'appuyer sur la nomenclature CCAM et les notes "
"d'inclusion/exclusion des codes concernés."
),
}
if td == "DP+DAS":
return (
"STRATÉGIE DE CONTESTATION — TYPE : DP + DAS (contestation combinée)\n"
+ blocs["DP"].split("\n", 1)[1] + "\n"
+ blocs["DAS"].split("\n", 1)[1]
)
return blocs.get(td, blocs["DP"])
def _build_cpam_prompt(
dossier: DossierMedical,
controle: ControleCPAM,
@@ -844,6 +877,9 @@ def _build_cpam_prompt(
+ "\n".join(ext_lines)
)
# Bloc de stratégie conditionnel selon le type de désaccord
strategie_type_str = _build_strategie_type(controle)
tags_disponibles_str = (
", ".join(f"[{t}]" for t in sorted(tag_map.keys()))
if tag_map else "(aucun)"
@@ -863,5 +899,6 @@ def _build_cpam_prompt(
bio_confrontation_str=bio_confrontation,
numero_ogc=controle.numero_ogc,
tags_disponibles_str=tags_disponibles_str,
strategie_type_str=strategie_type_str,
)
return prompt, tag_map

View File

@@ -6,6 +6,8 @@ import logging
import re
from pathlib import Path
from datetime import datetime, timedelta
import openpyxl
from ..config import ControleCPAM
@@ -15,6 +17,9 @@ logger = logging.getLogger(__name__)
# Colonnes attendues dans le fichier Excel
_EXPECTED_COLUMNS = ("N° OGC", "Titre", "Arg_UCR", "Décision_UCR", "DP_UCR", "DA_UCR", "DR_UCR", "Actes_UCR")
# Colonnes optionnelles de dates
_DATE_COLUMNS = ("Date_notification", "Date_limite")
def parse_cpam_excel(path: str | Path) -> dict[int, list[ControleCPAM]]:
"""Lit le fichier Excel de contrôle CPAM et retourne un dict OGC -> liste de contrôles.
@@ -76,6 +81,22 @@ def parse_cpam_excel(path: str | Path) -> dict[int, list[ControleCPAM]]:
dr_ucr=_clean_optional(row, col_map.get("DR_UCR")),
actes_ucr=_clean_optional(row, col_map.get("Actes_UCR")),
)
controle.type_desaccord = _infer_type_desaccord(controle)
# Dates réglementaires (optionnelles)
date_notif_raw = _clean_optional(row, col_map.get("Date_notification"))
date_limite_raw = _clean_optional(row, col_map.get("Date_limite"))
if date_notif_raw:
controle.date_notification = _parse_date(date_notif_raw)
if controle.date_notification and not date_limite_raw:
# Calculer la date limite (notification + 30 jours)
try:
dt = datetime.strptime(controle.date_notification, "%d/%m/%Y")
controle.date_limite_reponse = (dt + timedelta(days=30)).strftime("%d/%m/%Y")
except ValueError:
pass
if date_limite_raw:
controle.date_limite_reponse = _parse_date(date_limite_raw)
result.setdefault(numero_ogc, []).append(controle)
count += 1
@@ -84,6 +105,41 @@ def parse_cpam_excel(path: str | Path) -> dict[int, list[ControleCPAM]]:
return result
def _parse_date(raw: str) -> str | None:
"""Parse une date depuis l'Excel (formats courants) vers JJ/MM/AAAA."""
if not raw:
return None
raw = raw.strip()
# Si c'est un objet datetime (openpyxl peut retourner un datetime)
if hasattr(raw, "strftime"):
return raw.strftime("%d/%m/%Y")
for fmt in ("%d/%m/%Y", "%Y-%m-%d", "%d-%m-%Y", "%d.%m.%Y"):
try:
return datetime.strptime(raw, fmt).strftime("%d/%m/%Y")
except ValueError:
continue
return raw # retourner tel quel si format inconnu
def _infer_type_desaccord(controle: ControleCPAM) -> str | None:
"""Déduit le type de désaccord depuis les champs UCR renseignés.
Retourne None si aucun champ UCR n'est renseigné (données incomplètes).
"""
has_dp = bool(controle.dp_ucr)
has_das = bool(controle.da_ucr)
has_actes = bool(controle.actes_ucr)
if has_dp and has_das:
return "DP+DAS"
if has_dp:
return "DP"
if has_das:
return "DAS"
if has_actes:
return "Actes"
return None
def _clean_optional(row: tuple, idx: int | None) -> str | None:
"""Extrait une valeur optionnelle depuis une ligne Excel."""
if idx is None or idx >= len(row):
@@ -95,21 +151,58 @@ def _clean_optional(row: tuple, idx: int | None) -> str | None:
return val if val else None
def match_dossier_ogc(source_name: str, cpam_data: dict[int, list[ControleCPAM]]) -> list[ControleCPAM]:
def match_dossier_ogc(
source_name: str,
cpam_data: dict[int, list[ControleCPAM]],
structured_dir: Path | None = None,
) -> list[ControleCPAM]:
"""Cherche les contrôles CPAM correspondant à un dossier par préfixe OGC.
Le nom du dossier suit le format "17_23100690" où 17 est le N° OGC.
Stratégie de matching (par ordre de priorité) :
1. Regex sur le nom du répertoire (format "17_23100690" → OGC 17)
2. Fallback : chercher l'OGC dans les métadonnées du JSON fusionné
Args:
source_name: Nom du sous-dossier (ex: "17_23100690").
cpam_data: Dict OGC -> contrôles retourné par parse_cpam_excel().
structured_dir: Répertoire structured/ pour le fallback JSON (optionnel).
Returns:
Liste des contrôles CPAM pour cet OGC, ou liste vide.
"""
# 1. Match par nom de répertoire (méthode existante)
match = re.match(r"^(\d+)_", source_name)
if not match:
return []
if match:
ogc = int(match.group(1))
result = cpam_data.get(ogc, [])
if result:
return result
ogc = int(match.group(1))
return cpam_data.get(ogc, [])
# 2. Fallback : chercher l'OGC dans le JSON fusionné
if structured_dir is not None:
dossier_dir = structured_dir / source_name
if dossier_dir.is_dir():
import json
for json_file in dossier_dir.glob("*_fusionne_cim10.json"):
try:
data = json.loads(json_file.read_text(encoding="utf-8"))
# Chercher dans controles_cpam existants
for ctrl in data.get("controles_cpam", []):
ctrl_ogc = ctrl.get("numero_ogc")
if ctrl_ogc and ctrl_ogc in cpam_data:
logger.info(
"OGC %d trouvé via fallback JSON pour dossier '%s'",
ctrl_ogc, source_name,
)
return cpam_data[ctrl_ogc]
except Exception:
pass
# Log des OGC non matchés
if cpam_data:
available_ogcs = sorted(cpam_data.keys())
logger.warning(
"OGC non trouvé pour dossier '%s'. OGC disponibles : %s",
source_name, available_ogcs,
)
return []

View File

@@ -26,15 +26,15 @@ def _search_rag_for_control(controle: ControleCPAM, dossier: DossierMedical) ->
"""
try:
from ..medical.rag_search import search_similar_cpam
except Exception:
logger.warning("Index RAG non disponible pour la contre-argumentation")
except ImportError:
logger.error("CPAM RAG : module rag_search non disponible (faiss-cpu manquant ?)")
return []
try:
return _search_rag_queries(controle, dossier, search_similar_cpam)
except Exception:
logger.warning("Erreur RAG pour la contre-argumentation — génération sans sources",
exc_info=True)
logger.error("CPAM RAG : erreur recherche — contre-argumentation sans sources",
exc_info=True)
return []

View File

@@ -8,9 +8,13 @@ Orchestrateur principal — délègue aux sous-modules :
from __future__ import annotations
import json
import logging
import os
from datetime import datetime
from pathlib import Path
from ..config import ControleCPAM, DossierMedical, RAGSource, rule_enabled
from ..config import ControleCPAM, DossierMedical, RAGSource, STRUCTURED_DIR, rule_enabled
from ..medical.ollama_client import call_anthropic, call_ollama
from ..prompts import CPAM_EXTRACTION
@@ -50,6 +54,70 @@ from .cpam_validation import _CIM10_CODE_RE, _validate_adversarial as _validate_
logger = logging.getLogger(__name__)
def _save_version(
dossier: DossierMedical,
controle: ControleCPAM,
) -> None:
"""Sauvegarde la version actuelle de l'argumentaire avant régénération.
Stocke dans output/structured/{dossier}/_cpam_versions/{ogc}_{timestamp}.json
"""
if not controle.contre_argumentation and not controle.response_data:
return # rien à versionner
# Trouver le dossier structuré (depuis source_files ou source_file)
dossier_dir = None
if not STRUCTURED_DIR.is_dir():
logger.debug("Versioning : STRUCTURED_DIR inexistant, skip")
return
structured_dirs = [d for d in STRUCTURED_DIR.iterdir() if d.is_dir()]
# Tentative 1 : matcher un source_file contre les noms de sous-dossiers
candidates = list(dossier.source_files or [])
if dossier.source_file and dossier.source_file not in candidates:
candidates.append(dossier.source_file)
for src in candidates:
src_stem = Path(src).stem.replace(" ", "_")
for d in structured_dirs:
if src_stem in d.name:
dossier_dir = d
break
if dossier_dir:
break
if not dossier_dir:
logger.debug("Versioning : pas de dossier structuré trouvé, skip")
return
versions_dir = dossier_dir / "_cpam_versions"
versions_dir.mkdir(exist_ok=True)
# Compter les versions existantes pour cet OGC
existing = sorted(versions_dir.glob(f"{controle.numero_ogc}_*.json"))
version_num = len(existing) + 1
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{controle.numero_ogc}_{timestamp}_v{version_num}.json"
version_data = {
"numero_ogc": controle.numero_ogc,
"version": version_num,
"timestamp": timestamp,
"contre_argumentation": controle.contre_argumentation,
"response_data": controle.response_data,
"quality_tier": controle.quality_tier,
"validation_dim": controle.validation_dim,
}
(versions_dir / filename).write_text(
json.dumps(version_data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
logger.info(" Version %d sauvegardée : %s", version_num, filename)
def _extraction_pass(
dossier: DossierMedical,
controle: ControleCPAM,
@@ -121,6 +189,9 @@ def generate_cpam_response(
logger.info("CPAM : génération contre-argumentation pour OGC %d%s",
controle.numero_ogc, controle.titre)
# 0. Versioning — sauvegarder la version précédente avant d'écraser
_save_version(dossier, controle)
# 1. Passe 1 — Extraction structurée (compréhension avant argumentation)
extraction = _extraction_pass(dossier, controle)
degraded_pass1 = extraction is None
@@ -137,12 +208,12 @@ def generate_cpam_response(
prompt, tag_map = _build_cpam_prompt(dossier, controle, sources, extraction)
# 4. Appel LLM — Ollama (rôle cpam) > Haiku fallback
result = call_ollama(prompt, temperature=0.1, max_tokens=16000, role="cpam")
result = call_ollama(prompt, temperature=0.1, max_tokens=8000, role="cpam")
if result is not None:
logger.info(" Contre-argumentation via Ollama")
else:
logger.info(" Ollama indisponible → fallback Anthropic Haiku")
result = call_anthropic(prompt, temperature=0.1, max_tokens=16000)
result = call_anthropic(prompt, temperature=0.1, max_tokens=8000)
if result is not None:
logger.info(" Contre-argumentation via Anthropic Haiku")
@@ -213,8 +284,8 @@ def generate_cpam_response(
if adversarial_warnings:
adversarial_warnings.append(f"Score de confiance : {score}/10")
# 8b. Boucle de correction (max 2 retries)
max_corrections = 2
# 8b. Boucle de correction (configurable via T2A_CPAM_MAX_CORRECTIONS, défaut 2)
max_corrections = int(os.environ.get("T2A_CPAM_MAX_CORRECTIONS", "2"))
for attempt in range(max_corrections):
if not (validation
and not validation.get("coherent", True)