feat: cache Ollama + parallélisation ThreadPool + filtrage DAS renforcé + modules GHM/CPAM/export RUM

- Cache persistant JSON thread-safe pour les résultats Ollama (invalidation par modèle)
- Parallélisation des appels Ollama (ThreadPoolExecutor, 2 workers)
- 6 nouvelles règles de filtrage DAS parasites (doublons, ponctuation, OCR, labo, fragments)
- Client Ollama centralisé (mode JSON natif + retry)
- Module GHM (estimation CMD/sévérité)
- Module contrôle CPAM (parser + contre-argumentation RAG)
- Export RUM (format RSS)
- Viewer enrichi (détail dossier)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-12 13:44:34 +01:00
parent a00e5f1147
commit a58398f5d4
25 changed files with 2872 additions and 97 deletions

115
src/control/cpam_parser.py Normal file
View File

@@ -0,0 +1,115 @@
"""Parsing du fichier Excel de contrôle CPAM (UCR) et matching OGC."""
from __future__ import annotations
import logging
import re
from pathlib import Path
import openpyxl
from ..config import ControleCPAM
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")
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.
Args:
path: Chemin vers le fichier .xlsx CPAM.
Returns:
Dict avec le numéro OGC comme clé et la liste des contrôles associés.
"""
path = Path(path)
if not path.exists():
logger.error("Fichier CPAM introuvable : %s", path)
return {}
wb = openpyxl.load_workbook(path, read_only=True)
ws = wb[wb.sheetnames[0]]
# Lire l'en-tête
rows = ws.iter_rows(values_only=True)
header = next(rows, None)
if header is None:
logger.error("Fichier CPAM vide : %s", path)
return {}
# Construire le mapping colonne -> index
col_map = {}
for i, col_name in enumerate(header):
if col_name:
col_map[col_name.strip()] = i
# Vérifier les colonnes requises
missing = [c for c in _EXPECTED_COLUMNS[:4] if c not in col_map]
if missing:
logger.error("Colonnes manquantes dans le fichier CPAM : %s", missing)
return {}
result: dict[int, list[ControleCPAM]] = {}
count = 0
for row in rows:
ogc_val = row[col_map["N° OGC"]]
if ogc_val is None:
continue
try:
numero_ogc = int(ogc_val)
except (ValueError, TypeError):
logger.warning("N° OGC invalide ignoré : %s", ogc_val)
continue
controle = ControleCPAM(
numero_ogc=numero_ogc,
titre=str(row[col_map.get("Titre", 1)] or "").strip(),
arg_ucr=str(row[col_map.get("Arg_UCR", 2)] or "").strip(),
decision_ucr=str(row[col_map.get("Décision_UCR", 3)] or "").strip(),
dp_ucr=_clean_optional(row, col_map.get("DP_UCR")),
da_ucr=_clean_optional(row, col_map.get("DA_UCR")),
dr_ucr=_clean_optional(row, col_map.get("DR_UCR")),
actes_ucr=_clean_optional(row, col_map.get("Actes_UCR")),
)
result.setdefault(numero_ogc, []).append(controle)
count += 1
logger.info("CPAM : %d contrôles chargés pour %d OGC distincts", count, len(result))
return result
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):
return None
val = row[idx]
if val is None:
return None
val = str(val).strip()
return val if val else None
def match_dossier_ogc(source_name: str, cpam_data: dict[int, list[ControleCPAM]]) -> 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.
Args:
source_name: Nom du sous-dossier (ex: "17_23100690").
cpam_data: Dict OGC -> contrôles retourné par parse_cpam_excel().
Returns:
Liste des contrôles CPAM pour cet OGC, ou liste vide.
"""
match = re.match(r"^(\d+)_", source_name)
if not match:
return []
ogc = int(match.group(1))
return cpam_data.get(ogc, [])