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:
@@ -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
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user