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>
This commit is contained in:
dom
2026-03-07 19:11:27 +01:00
parent 2478928798
commit 1e837c2758
13 changed files with 1694 additions and 1103 deletions

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

@@ -5,10 +5,58 @@ build-backend = "setuptools.backends._legacy:_Backend"
[project] [project]
name = "t2a" name = "t2a"
version = "2.0.0" version = "2.0.0"
requires-python = ">=3.12" description = "Pipeline de codage CIM-10/CCAM automatise pour le PMSI hospitalier"
readme = "README.md"
requires-python = ">=3.11"
authors = [
{ name = "Equipe T2A" },
]
dependencies = [
"pdfplumber>=0.10.0",
"transformers>=4.35.0,<6.0.0",
"torch>=2.1.0",
"protobuf>=3.20.0,<7.0.0",
"regex>=2023.0",
"pydantic>=2.5.0",
"sentencepiece>=0.1.99,<0.3.0",
"edsnlp[ml]>=0.17.0",
"faiss-cpu>=1.7.0",
"sentence-transformers>=2.2.0",
"requests>=2.28.0",
"flask>=3.0.0",
"flask-httpauth>=4.0.0",
"python-dotenv>=1.0.0",
"openpyxl>=3.0.0",
"pandas>=2.0.0",
"PyMuPDF>=1.24.0",
"python-docx>=1.0.0",
"PyYAML>=6.0",
"gunicorn>=22.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=7.4.0",
"ruff>=0.4.0",
]
[project.scripts]
t2a = "src.main:main"
[tool.setuptools.packages.find]
include = ["src*"]
[tool.ruff]
target-version = "py311"
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "W", "I"]
ignore = ["E501"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
python_files = ["test_*.py"]
addopts = "--strict-markers -x -q" addopts = "--strict-markers -x -q"
markers = ["integration: tests requiring Ollama"] markers = ["integration: tests requiring Ollama"]

View File

@@ -37,19 +37,6 @@ from .cpam_validation import (
_guardian_deterministic, _guardian_deterministic,
) )
# Backward compat — sera retiré dans un commit futur
from .cpam_rag import _search_rag_queries # noqa: F401
from .cpam_context import ( # noqa: F401
_get_code_label,
_get_cim10_definitions,
_BIO_INTERPRETATION,
_BIO_THRESHOLDS,
_assess_dossier_strength,
_build_bio_summary,
_build_bio_confrontation,
_check_das_bio_coherence,
)
from .cpam_validation import _CIM10_CODE_RE, _validate_adversarial as _validate_adversarial, _assess_quality_tier as _assess_quality_tier, _fuzzy_match_ref as _fuzzy_match_ref, _sanitize_unauthorized_codes as _sanitize_unauthorized_codes # noqa: F401
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -54,12 +54,6 @@ from .validation_pipeline import (
_validate_justifications, _validate_justifications,
) )
# Backward compat — sera retiré dans un commit futur
from .bio_normals import BIO_NORMALS, _is_abnormal # noqa: F401
from .validation_pipeline import _is_dp_family_redundant # noqa: F401
from .diagnostic_extraction import _lookup_cim10 # noqa: F401
from .diagnostic_extraction import _DAS_PATTERNS # noqa: F401
from .diagnostic_extraction import _detect_nutrition_has2021 # noqa: F401
def extract_medical_info( def extract_medical_info(

File diff suppressed because it is too large Load Diff

105
src/viewer/bp_rules.py Normal file
View File

@@ -0,0 +1,105 @@
"""Blueprint Flask pour la gestion des règles métier (CRUD YAML)."""
from __future__ import annotations
import logging
from flask import Blueprint, render_template, request, jsonify
from .rules_manager import (
list_rule_files,
load_rule_file,
toggle_rule,
update_rule_field,
add_rule,
delete_rule,
_find_file,
)
logger = logging.getLogger(__name__)
bp_rules = Blueprint("rules", __name__)
@bp_rules.route("/admin/rules")
def admin_rules():
"""Page principale de gestion des règles."""
files = list_rule_files()
# Pré-charger le contenu de chaque fichier
for f in files:
if f["exists"]:
f["data"] = load_rule_file(f["id"])
return render_template("admin_rules.html", rule_files=files)
@bp_rules.route("/api/rules/<file_id>")
def api_get_rules(file_id: str):
"""Retourne le contenu complet d'un fichier de règles."""
try:
rf = _find_file(file_id)
data = load_rule_file(file_id)
return jsonify({"ok": True, "file_id": file_id, "label": rf["label"], "data": data})
except ValueError as e:
return jsonify({"error": str(e)}), 404
@bp_rules.route("/api/rules/<file_id>/toggle", methods=["POST"])
def api_toggle_rule(file_id: str):
"""Active/désactive une règle."""
body = request.get_json(silent=True) or {}
rule_path = body.get("rule_path", "")
enabled = body.get("enabled", True)
if not rule_path:
return jsonify({"error": "rule_path requis"}), 400
try:
data = toggle_rule(file_id, rule_path, enabled)
return jsonify({"ok": True, "data": data})
except (ValueError, KeyError) as e:
return jsonify({"error": str(e)}), 400
@bp_rules.route("/api/rules/<file_id>/update", methods=["POST"])
def api_update_rule(file_id: str):
"""Met à jour un champ d'une règle."""
body = request.get_json(silent=True) or {}
rule_path = body.get("rule_path", "")
field = body.get("field", "")
value = body.get("value")
if not rule_path or not field:
return jsonify({"error": "rule_path et field requis"}), 400
try:
data = update_rule_field(file_id, rule_path, field, value)
return jsonify({"ok": True, "data": data})
except (ValueError, KeyError) as e:
return jsonify({"error": str(e)}), 400
@bp_rules.route("/api/rules/<file_id>/add", methods=["POST"])
def api_add_rule(file_id: str):
"""Ajoute une nouvelle règle."""
body = request.get_json(silent=True) or {}
parent_path = body.get("parent_path", "")
rule_id = body.get("rule_id", "").strip()
rule_data = body.get("rule_data", {})
if not parent_path or not rule_id:
return jsonify({"error": "parent_path et rule_id requis"}), 400
try:
data = add_rule(file_id, parent_path, rule_id, rule_data)
return jsonify({"ok": True, "data": data})
except ValueError as e:
return jsonify({"error": str(e)}), 400
@bp_rules.route("/api/rules/<file_id>/delete", methods=["POST"])
def api_delete_rule(file_id: str):
"""Supprime une règle."""
body = request.get_json(silent=True) or {}
parent_path = body.get("parent_path", "")
rule_id = body.get("rule_id", "").strip()
if not parent_path or not rule_id:
return jsonify({"error": "parent_path et rule_id requis"}), 400
try:
data = delete_rule(file_id, parent_path, rule_id)
return jsonify({"ok": True, "data": data})
except ValueError as e:
return jsonify({"error": str(e)}), 400

702
src/viewer/helpers.py Normal file
View File

@@ -0,0 +1,702 @@
"""Fonctions utilitaires et filtres Jinja2 pour le viewer T2A."""
from __future__ import annotations
import json
import logging
import re
import time
from collections import Counter
from pathlib import Path
from markupsafe import Markup
from ..config import (
ANONYMIZED_DIR, STRUCTURED_DIR, CCAM_DICT_PATH, DossierMedical,
CIM10_PDF, GUIDE_METHODO_PDF, CCAM_PDF, CIM10_DICT_PATH, CIM10_SUPPLEMENTS_PATH,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Helpers — statistiques & données
# ---------------------------------------------------------------------------
def compute_group_stats(items: list[dict]) -> dict:
das_count = 0
alertes_count = 0
actes_count = 0
cma_count = 0
for item in items:
d = item["dossier"]
das_count += len(d.diagnostics_associes)
alertes_count += len(d.alertes_codage)
actes_count += len(d.actes_ccam)
for diag in d.diagnostics_associes:
if diag.est_cma:
cma_count += 1
if d.diagnostic_principal and d.diagnostic_principal.est_cma:
cma_count += 1
return {"das_count": das_count, "alertes_count": alertes_count,
"actes_count": actes_count, "cma_count": cma_count}
def compute_dashboard_stats(groups: dict[str, list[dict]]) -> dict:
total_dossiers = len(groups)
total_fichiers = 0
total_das = 0
total_actes = 0
total_alertes = 0
total_cma = 0
total_cpam = 0
dp_confidence: Counter = Counter()
dp_validity: Counter = Counter()
code_counter: Counter = Counter()
ghm_types: Counter = Counter()
severity_dist: Counter = Counter()
processing_times: list[float] = []
for items in groups.values():
total_fichiers += len(items)
for item in items:
d = item["dossier"]
total_das += len(d.diagnostics_associes)
total_actes += len(d.actes_ccam)
total_alertes += len(d.alertes_codage)
total_cpam += len(d.controles_cpam)
if d.processing_time_s is not None:
processing_times.append(d.processing_time_s)
dp = d.diagnostic_principal
if dp:
dp_confidence[dp.cim10_confidence or "none"] += 1
if dp.cim10_suggestion:
dp_validity["valide"] += 1
code_counter[dp.cim10_suggestion] += 1
else:
dp_validity["absent"] += 1
else:
dp_confidence["none"] += 1
dp_validity["absent"] += 1
for das in d.diagnostics_associes:
if das.cim10_suggestion:
code_counter[das.cim10_suggestion] += 1
if das.est_cma:
total_cma += 1
if dp and dp.est_cma:
total_cma += 1
ghm = d.ghm_estimation
if ghm:
if ghm.type_ghm:
ghm_types[ghm.type_ghm] += 1
severity_dist[ghm.severite] += 1
top_codes = code_counter.most_common(15)
top_max = top_codes[0][1] if top_codes else 1
return {
"total_dossiers": total_dossiers,
"total_fichiers": total_fichiers,
"total_das": total_das,
"total_actes": total_actes,
"total_alertes": total_alertes,
"total_cma": total_cma,
"total_cpam": total_cpam,
"dp_confidence": dict(dp_confidence),
"dp_validity": dict(dp_validity),
"top_codes": top_codes,
"top_max": top_max,
"ghm_types": dict(ghm_types),
"severity_dist": dict(severity_dist),
"processing_time_total": sum(processing_times),
"processing_time_avg": sum(processing_times) / len(processing_times) if processing_times else 0,
}
def compute_dim_synthesis(groups: dict[str, list[dict]]) -> dict:
dp_total = 0
dp_confirmed = 0
dp_review = 0
dp_modified = 0
dp_conf_dist: Counter = Counter()
dp_source_dist: Counter = Counter()
das_total = 0
das_kept = 0
das_downgraded = 0
das_removed = 0
das_ruled_out = 0
das_cma = 0
das_no_code = 0
veto_dist: Counter = Counter()
veto_scores: list[int] = []
top_vetos: Counter = Counter()
completude_dist: Counter = Counter()
completude_scores: list[int] = []
cpam_total = 0
cpam_impact_total = 0
cpam_by_priority: Counter = Counter()
cpam_by_status: Counter = Counter()
dossiers_review: list[dict] = []
dossiers_fail: list[dict] = []
dossiers_indefendable: list[dict] = []
for group_name, items in groups.items():
for item in items:
d = item["dossier"]
dname = format_dossier_name(group_name)
dpath = item["path_rel"]
dp_final = d.dp_final
dp_track = d.dp_trackare
if dp_final:
dp_total += 1
dp_conf_dist[dp_final.confidence or "none"] += 1
if dp_final.verdict == "CONFIRMED":
dp_confirmed += 1
else:
dp_review += 1
dossiers_review.append({"name": dname, "path": dpath,
"reason": dp_final.reason or "DP à valider",
"code": dp_final.chosen_code or "?"})
if dp_track and dp_final.chosen_code and dp_track.chosen_code:
if dp_final.chosen_code != dp_track.chosen_code:
dp_modified += 1
flags = d.quality_flags or {}
if flags.get("trackare_only_mode"):
dp_source_dist["trackare"] += 1
elif flags.get("crh_only_mode"):
dp_source_dist["crh"] += 1
elif flags.get("override_trackare_by_crh_confirmed") or flags.get("trackare_symptom_overridden"):
dp_source_dist["override_crh"] += 1
elif flags.get("trackare_confirmed_by_crh"):
dp_source_dist["confirmé"] += 1
else:
dp_source_dist["autre"] += 1
elif d.diagnostic_principal:
dp_total += 1
dp_conf_dist[d.diagnostic_principal.cim10_confidence or "none"] += 1
for das in d.diagnostics_associes:
das_total += 1
dec = das.cim10_decision
if dec:
action = dec.action
if action == "KEEP":
das_kept += 1
elif action == "DOWNGRADE":
das_downgraded += 1
elif action == "REMOVE":
das_removed += 1
elif action == "RULED_OUT":
das_ruled_out += 1
else:
das_kept += 1
else:
das_kept += 1
if das.est_cma:
das_cma += 1
if not das.cim10_final and not das.cim10_suggestion:
das_no_code += 1
vr = d.veto_report
if vr:
veto_dist[vr.verdict] += 1
veto_scores.append(vr.score_contestabilite)
for issue in (vr.issues or []):
top_vetos[issue.veto] += 1
if vr.verdict == "FAIL":
dossiers_fail.append({"name": dname, "path": dpath,
"score": vr.score_contestabilite,
"issues": len(vr.issues or [])})
comp = d.completude
if comp:
completude_dist[comp.verdict_global] += 1
completude_scores.append(comp.score_global)
if comp.verdict_global == "indefendable":
dossiers_indefendable.append({"name": dname, "path": dpath,
"score": comp.score_global,
"manquants": len(comp.documents_manquants or [])})
for ctrl in d.controles_cpam:
cpam_total += 1
fi = ctrl.financial_impact
if fi:
cpam_impact_total += fi.impact_estime_euros or 0
cpam_by_priority[fi.priorite or "normale"] += 1
cpam_by_status[ctrl.validation_dim or "non_valide"] += 1
avg_veto = round(sum(veto_scores) / len(veto_scores)) if veto_scores else 0
avg_completude = round(sum(completude_scores) / len(completude_scores)) if completude_scores else 0
return {
"dp": {"total": dp_total, "confirmed": dp_confirmed, "review": dp_review,
"modified": dp_modified, "confidence": dict(dp_conf_dist),
"source": dict(dp_source_dist)},
"das": {"total": das_total, "kept": das_kept, "downgraded": das_downgraded,
"removed": das_removed, "ruled_out": das_ruled_out, "cma": das_cma,
"no_code": das_no_code,
"taux_modification": round((das_downgraded + das_removed + das_ruled_out) / das_total * 100, 1) if das_total else 0},
"veto": {"distribution": dict(veto_dist), "avg_score": avg_veto,
"top_issues": top_vetos.most_common(10)},
"completude": {"distribution": dict(completude_dist), "avg_score": avg_completude},
"cpam": {"total": cpam_total, "impact_total": cpam_impact_total,
"by_priority": dict(cpam_by_priority), "by_status": dict(cpam_by_status)},
"alertes": {"review": dossiers_review[:20], "fail": dossiers_fail[:20],
"indefendable": dossiers_indefendable[:20]},
}
def _compute_jours_restants(ctrl) -> int | None:
if not ctrl.date_limite_reponse:
return None
from datetime import datetime
try:
limite = datetime.strptime(ctrl.date_limite_reponse, "%d/%m/%Y")
return (limite - datetime.now()).days
except (ValueError, TypeError):
return None
def collect_cpam_controls(groups: dict[str, list[dict]]) -> list[dict]:
from ..medical.ghm import estimate_financial_impact
_PRIORITE_ORDER = {"critique": 0, "haute": 1, "normale": 2, "faible": 3}
controls = []
for group_name, items in groups.items():
for item in items:
d = item["dossier"]
dp_code = d.diagnostic_principal.cim10_suggestion if d.diagnostic_principal else None
for ctrl in d.controles_cpam:
if ctrl.financial_impact is None and d.ghm_estimation:
ctrl.financial_impact = estimate_financial_impact(d.ghm_estimation)
controls.append({
"group_name": group_name, "filepath": item["path_rel"],
"ctrl": ctrl, "dp_code": dp_code,
"jours_restants": _compute_jours_restants(ctrl),
})
controls.sort(key=lambda c: (
_PRIORITE_ORDER.get(
c["ctrl"].financial_impact.priorite if c["ctrl"].financial_impact else "normale", 2),
0 if "confirme" in (c["ctrl"].decision_ucr or "").lower() else 1,
c["ctrl"].numero_ogc,
))
return controls
def get_builtin_referentiels() -> list[dict]:
from ..config import BASE_DIR, REFERENTIELS_DIR
import datetime as _dt
rag_index_dir = BASE_DIR / "data" / "rag_index"
chunks_by_doc: dict[str, int] = {}
for meta_file in rag_index_dir.glob("metadata*.json"):
try:
meta = json.loads(meta_file.read_text(encoding="utf-8"))
for m in meta:
doc = m.get("document", "")
chunks_by_doc[doc] = chunks_by_doc.get(doc, 0) + 1
except Exception:
pass
refs = []
builtin_sources = [
("CIM-10 FR 2026", CIM10_PDF, ".pdf", ["cim10", "cim10_alpha"], "11/12/2025", "2026 (provisoire)"),
("Guide Méthodologique MCO 2026", GUIDE_METHODO_PDF, ".pdf", ["guide_methodo"], "2025", "2026 (provisoire)"),
("CCAM descriptive PMSI V4", CCAM_PDF, ".pdf", ["ccam"], "2025", "V4 2025"),
("Dictionnaire CIM-10", CIM10_DICT_PATH, ".json", [], "", ""),
("Suppléments CIM-10", CIM10_SUPPLEMENTS_PATH, ".json", [], "", ""),
("Dictionnaire CCAM", CCAM_DICT_PATH, ".json", [], "", ""),
]
for name, path, ext, doc_keys, edition, validite in builtin_sources:
size_mb = path.stat().st_size / (1024 * 1024) if path.exists() else 0
mtime = ""
if path.exists():
mtime = _dt.datetime.fromtimestamp(path.stat().st_mtime).strftime("%d/%m/%Y")
chunks = sum(chunks_by_doc.get(k, 0) for k in doc_keys)
refs.append({"name": name, "filename": path.name, "extension": ext,
"size_mb": size_mb, "chunks": chunks, "exists": path.exists(),
"edition": edition, "validite": validite, "file_date": mtime})
pdfs_dir = REFERENTIELS_DIR / "pdfs"
for doc_name, count in sorted(chunks_by_doc.items()):
if doc_name.startswith("ref:") or doc_name.startswith("proc:"):
prefix, fname = doc_name.split(":", 1)
pdf_path = pdfs_dir / fname
size_mb = pdf_path.stat().st_size / (1024 * 1024) if pdf_path.exists() else 0
mtime = ""
if pdf_path.exists():
mtime = _dt.datetime.fromtimestamp(pdf_path.stat().st_mtime).strftime("%d/%m/%Y")
refs.append({"name": fname.replace("_", " ").replace(".pdf", ""),
"filename": fname, "extension": ".pdf", "size_mb": size_mb,
"chunks": count, "exists": pdf_path.exists(), "edition": "",
"validite": "", "file_date": mtime, "category": prefix})
return refs
def get_faiss_index_info() -> dict:
from ..config import BASE_DIR
from ..medical.rag_index import check_faiss_ready
import datetime as _dt
rag_dir = BASE_DIR / "data" / "rag_index"
info = {"ok": False, "indexes": [], "total_vectors": 0, "last_build": ""}
status = check_faiss_ready()
info["ok"] = status["ok"]
info["total_vectors"] = status["ref"] + status["proc"] + status["bio"] + status["legacy"]
for kind, label in [("ref", "Référentiels CIM-10"), ("proc", "Procédures/Guides"),
("bio", "Biologie"), ("all", "Legacy (combiné)")]:
idx_file = rag_dir / f"faiss_{kind}.index" if kind != "all" else rag_dir / "faiss.index"
count = status.get(kind, status.get("legacy", 0)) if kind == "all" else status.get(kind, 0)
mtime = ""
size_mb = 0
if idx_file.exists():
mtime = _dt.datetime.fromtimestamp(idx_file.stat().st_mtime).strftime("%d/%m/%Y %H:%M")
size_mb = idx_file.stat().st_size / (1024 * 1024)
info["indexes"].append({"kind": kind, "label": label, "vectors": count,
"size_mb": round(size_mb, 1), "last_build": mtime,
"exists": idx_file.exists()})
if mtime and (not info["last_build"] or mtime > info["last_build"]):
info["last_build"] = mtime
return info
def load_ccam_dict() -> dict[str, dict]:
if CCAM_DICT_PATH.exists():
try:
return json.loads(CCAM_DICT_PATH.read_text(encoding="utf-8"))
except Exception:
logger.warning("Impossible de charger le dictionnaire CCAM")
return {}
_scan_cache: dict[str, object] = {"data": None, "ts": 0.0}
_SCAN_TTL = 30
def scan_dossiers() -> dict[str, list[dict]]:
now = time.monotonic()
if _scan_cache["data"] is not None and (now - _scan_cache["ts"]) < _SCAN_TTL:
return _scan_cache["data"]
groups: dict[str, list[dict]] = {}
for json_path in sorted(STRUCTURED_DIR.rglob("*.json")):
rel = json_path.relative_to(STRUCTURED_DIR)
parts = rel.parts
group_name = "racine" if len(parts) == 1 else str(Path(*parts[:-1]))
try:
data = json.loads(json_path.read_text(encoding="utf-8"))
dossier = DossierMedical.model_validate(data)
except Exception:
logger.warning("Impossible de charger %s", json_path)
continue
groups.setdefault(group_name, []).append({
"name": json_path.stem, "path_rel": str(rel), "dossier": dossier,
})
_scan_cache["data"] = groups
_scan_cache["ts"] = now
return groups
def load_dossier(path_rel: str) -> DossierMedical:
from flask import abort
safe_path = (STRUCTURED_DIR / path_rel).resolve()
if not safe_path.is_relative_to(STRUCTURED_DIR.resolve()):
abort(403)
if not safe_path.exists():
abort(404)
data = json.loads(safe_path.read_text(encoding="utf-8"))
return DossierMedical.model_validate(data)
def fetch_ollama_models() -> list[str]:
import requests
from .. import config as cfg
try:
resp = requests.get(f"{cfg.OLLAMA_URL}/api/tags", timeout=5)
resp.raise_for_status()
return [m["name"] for m in resp.json().get("models", [])]
except Exception:
logger.warning("Impossible de contacter Ollama pour lister les modèles")
return []
# ---------------------------------------------------------------------------
# Filtres Jinja2
# ---------------------------------------------------------------------------
_CONFIDENCE_COLORS = {
"high": ("#16a34a", "#dcfce7"),
"medium": ("#ca8a04", "#fef9c3"),
"low": ("#dc2626", "#fee2e2"),
}
_CONFIDENCE_LABELS = {"high": "Haute", "medium": "Moyenne", "low": "Basse"}
_CONFIDENCE_TIPS = {
"high": "Confiance haute — le pipeline est très sûr de ce code CIM-10",
"medium": "Confiance moyenne — le code est probable mais mérite vérification",
"low": "Confiance basse — code incertain, relecture médicale recommandée",
}
def confidence_badge(value: str | None) -> Markup:
if not value:
return Markup("")
fg, bg = _CONFIDENCE_COLORS.get(value, ("#6b7280", "#f3f4f6"))
label = _CONFIDENCE_LABELS.get(value, value)
tip = _CONFIDENCE_TIPS.get(value, "Niveau de confiance du pipeline sur ce code")
return Markup(
f'<span title="{tip}" style="display:inline-block;padding:2px 8px;border-radius:9999px;'
f'font-size:0.75rem;font-weight:600;color:{fg};background:{bg}">{label}</span>')
def confidence_label(value: str | None) -> str:
if not value:
return ""
return _CONFIDENCE_LABELS.get(value, value)
_SEVERITY_STYLES = {
"severe": ("Sévère", "#dc2626", "#fee2e2"),
"modere": ("Modéré", "#92400e", "#fef3c7"),
"leger": ("Léger", "#065f46", "#d1fae5"),
}
_CMA_LEVEL_STYLES = {
1: ("1", "#6b7280", "#f3f4f6"),
2: ("2", "#065f46", "#d1fae5"),
3: ("3", "#92400e", "#fef3c7"),
4: ("4", "#dc2626", "#fee2e2"),
}
def format_duration(seconds: float | None) -> str:
if seconds is None:
return ""
if seconds < 60:
return f"{seconds:.1f}s"
minutes = int(seconds // 60)
secs = int(seconds % 60)
if secs == 0:
return f"{minutes}min"
return f"{minutes}min {secs:02d}s"
_SEVERITY_TIPS = {
"severe": "Impact clinique sévère — complication ou morbidité majeure augmentant significativement la valorisation T2A",
"modere": "Impact clinique modéré — complication ou morbidité d'importance intermédiaire",
"leger": "Impact clinique léger — séjour sans complication significative",
}
def severity_badge(value: str | None) -> Markup:
if not value or value not in _SEVERITY_STYLES:
return Markup("")
label, fg, bg = _SEVERITY_STYLES[value]
tip = _SEVERITY_TIPS.get(value, "")
return Markup(
f'<span title="{tip}" style="display:inline-block;padding:2px 8px;border-radius:9999px;'
f'font-size:0.75rem;font-weight:600;color:{fg};background:{bg}">{label}</span>')
def cma_level_badge(value: int | None) -> Markup:
if value is None or value < 1:
return Markup("")
level = min(value, 4)
label, fg, bg = _CMA_LEVEL_STYLES.get(level, _CMA_LEVEL_STYLES[1])
title = {
1: "Pas de CMA — ce diagnostic n'augmente pas la sévérité du GHM",
2: "CMA niveau 2 — comorbidité mineure augmentant légèrement la sévérité",
3: "CMA niveau 3 — comorbidité majeure augmentant significativement la sévérité",
4: "CMA niveau 4 — comorbidité très sévère (réanimation, décès, etc.)",
}.get(level, "")
return Markup(
f'<span title="{title}" style="display:inline-block;padding:2px 8px;border-radius:9999px;'
f'font-size:0.75rem;font-weight:600;white-space:nowrap;color:{fg};background:{bg}">CMA {label}</span>')
def format_dossier_name(name: str) -> str:
if name == "racine":
return "Non classés"
return name
def format_doc_name(name: str) -> str:
n = name.lower()
if "fusionne" in n:
return "Fusionné"
if n.startswith("cro") or n.startswith("crh"):
return name.split("_")[0].upper()
if "trackare" in n:
return "Trackare"
if "anapath" in n:
return "Anapath"
return name
def decision_badge(decision) -> Markup:
if not decision:
return Markup("")
action = decision.get("action", "KEEP") if isinstance(decision, dict) else getattr(decision, "action", "KEEP")
if action == "KEEP":
return Markup("")
labels = {
"DOWNGRADE": ("Rétrogradé", "#fef3c7", "#92400e", "Le niveau de confiance de ce diagnostic a été abaissé par le moteur de règles"),
"REMOVE": ("Supprimé", "#fee2e2", "#dc2626", "Ce diagnostic a été retiré du codage car jugé non pertinent ou non étayé"),
"RULED_OUT": ("Écarté (Contradiction)", "#f1f5f9", "#64748b", "Ce diagnostic a été écarté car il contredit une règle ATIH (exclusion, doublon, etc.)"),
"NEED_INFO": ("Preuve manquante", "#fff7ed", "#c2410c", "Ce diagnostic nécessite des preuves cliniques supplémentaires pour être validé"),
"PROMOTE_DP": ("Promu en DP", "#dbeafe", "#1d4ed8", "Ce diagnostic a été promu en Diagnostic Principal car plus pertinent que le DP initial"),
}
info = labels.get(action, (action, "#f1f5f9", "#64748b", ""))
label, bg, fg = info[0], info[1], info[2]
tip = info[3] if len(info) > 3 else ""
return Markup(f'<span class="badge" style="background:{bg};color:{fg};font-size:0.7rem;" title="{tip}">{label}</span>')
def format_cpam_text(text: str | None) -> Markup:
if not text:
return Markup("")
from markupsafe import escape
lines = str(text).split("\n")
html_parts: list[str] = []
in_list = False
for line in lines:
stripped = line.strip()
if not stripped:
if in_list:
html_parts.append("</ul>")
in_list = False
html_parts.append("<br>")
continue
if stripped.startswith("- "):
if not in_list:
html_parts.append("<ul style='margin:0.3rem 0;padding-left:1.2rem;'>")
in_list = True
html_parts.append(f"<li>{escape(stripped[2:])}</li>")
else:
if in_list:
html_parts.append("</ul>")
in_list = False
html_parts.append(f"<p style='margin:0.2rem 0;'>{escape(stripped)}</p>")
if in_list:
html_parts.append("</ul>")
return Markup("\n".join(html_parts))
def human_where(value: str | None) -> str:
if not value:
return "Global"
if value == "diagnostic_principal":
return "Diagnostic Principal"
if value == "diagnostics_associes":
return "Diagnostics Associés"
if value == "sejour":
return "Séjour"
m = re.match(r"diagnostics_associes\[(\d+)\]", value)
if m:
return f"DAS n°{int(m.group(1)) + 1}"
m = re.match(r"actes_ccam\[(\d+)\]", value)
if m:
return f"Acte n°{int(m.group(1)) + 1}"
return value
def _date_to_iso(date_fr: str) -> str:
try:
parts = date_fr.strip().split("/")
if len(parts) == 3:
return f"{parts[2]}-{parts[1]}-{parts[0]}"
except Exception:
pass
return ""
_status_cache: dict[str, object] = {"data": None, "ts": 0.0}
_STATUS_TTL = 120
def _get_system_status() -> list[dict]:
import os
import requests
now = time.monotonic()
if _status_cache["data"] is not None and (now - _status_cache["ts"]) < _STATUS_TTL:
return _status_cache["data"]
from ..config import OLLAMA_URL, OLLAMA_MODELS
components = []
components.append({"name": "Moteur de règles (VetoEngine)", "status": True, "detail": "Actif"})
ollama_ok = False
ollama_detail = "Non disponible"
try:
r = requests.get(f"{OLLAMA_URL}/api/tags", timeout=3)
if r.status_code == 200:
ollama_ok = True
ollama_detail = ", ".join(f"{role}={model}" for role, model in OLLAMA_MODELS.items())
except Exception:
pass
components.append({"name": "LLM Ollama", "status": ollama_ok, "detail": ollama_detail})
api_key = os.environ.get("ANTHROPIC_API_KEY", "")
components.append({"name": "Fallback Anthropic (Haiku)", "status": bool(api_key),
"detail": "Clé configurée" if api_key else "Clé absente"})
try:
from ..medical.rag_index import check_faiss_ready
faiss_check = check_faiss_ready()
if faiss_check["ok"]:
total = faiss_check["ref"] + faiss_check["proc"] + faiss_check["bio"] + faiss_check["legacy"]
parts = []
if faiss_check["ref"]:
parts.append(f"ref={faiss_check['ref']}")
if faiss_check["proc"]:
parts.append(f"proc={faiss_check['proc']}")
if faiss_check["bio"]:
parts.append(f"bio={faiss_check['bio']}")
detail = f"{total} vecteurs ({', '.join(parts)})"
else:
detail = "; ".join(faiss_check["errors"][:2])
components.append({"name": "Index FAISS (RAG)", "status": faiss_check["ok"], "detail": detail})
except Exception as e:
components.append({"name": "Index FAISS (RAG)", "status": False,
"detail": f"Erreur vérification : {e}"})
components.append({"name": "Extraction PDF (pdfplumber)", "status": True, "detail": "Actif"})
ner_ok = False
try:
from transformers import AutoTokenizer
AutoTokenizer.from_pretrained("Jean-Baptiste/camembert-ner", local_files_only=True)
ner_ok = True
except Exception:
pass
components.append({"name": "Anonymisation NER (CamemBERT)", "status": ner_ok,
"detail": "Modèle en cache" if ner_ok else "Modèle non trouvé"})
emb_ok = False
try:
from huggingface_hub import try_to_load_from_cache
result = try_to_load_from_cache("dangvantuan/sentence-camembert-large", "config.json")
emb_ok = result is not None and isinstance(result, str)
except Exception:
pass
components.append({"name": "Embeddings (sentence-camembert-large)", "status": emb_ok,
"detail": "Modèle en cache" if emb_ok else "Modèle non trouvé"})
_status_cache["data"] = components
_status_cache["ts"] = now
return components
def _sort_qc_alerts(alerts: list[str]) -> list[str]:
def _key(a: str) -> tuple[int, int]:
text = a.lower()
dp = 0 if " dp " in text or text.startswith("dp ") or "diagnostic principal" in text else 1
critical = 0 if any(k in text for k in ("high→low", "high → low", "à reconsidérer", "reconsider")) else 1
return (dp, critical)
return sorted(alerts, key=_key)
def register_filters(app):
"""Enregistre tous les filtres Jinja2 sur l'application Flask."""
app.jinja_env.filters["confidence_badge"] = confidence_badge
app.jinja_env.filters["confidence_label"] = confidence_label
app.jinja_env.filters["severity_badge"] = severity_badge
app.jinja_env.filters["cma_level_badge"] = cma_level_badge
app.jinja_env.filters["format_duration"] = format_duration
app.jinja_env.filters["format_dossier_name"] = format_dossier_name
app.jinja_env.filters["format_doc_name"] = format_doc_name
app.jinja_env.filters["format_cpam_text"] = format_cpam_text
app.jinja_env.filters["decision_badge"] = decision_badge
app.jinja_env.filters["human_where"] = human_where
app.jinja_env.filters["date_to_iso"] = _date_to_iso
app.jinja_env.filters["sort_qc_alerts"] = _sort_qc_alerts

218
src/viewer/rules_manager.py Normal file
View File

@@ -0,0 +1,218 @@
"""Gestionnaire CRUD pour les fichiers de règles YAML."""
from __future__ import annotations
import logging
from pathlib import Path
import yaml
from ..config import CONFIG_DIR, RULES_DIR
logger = logging.getLogger(__name__)
# Fichiers de règles gérables via l'UI
RULE_FILES: list[dict] = [
{
"id": "base",
"path": RULES_DIR / "base.yaml",
"label": "Vetos & Decisions (socle)",
"description": "Packs de règles activables : vetos de contestabilité et décisions automatiques.",
"structure": "packs",
},
{
"id": "bio_rules",
"path": CONFIG_DIR / "bio_rules.yaml",
"label": "Règles biologiques",
"description": "Contradiction bio → écartement automatique (ruled_out) ou alerte VETO-17.",
"structure": "flat_rules",
},
{
"id": "diagnostic_conflicts",
"path": CONFIG_DIR / "diagnostic_conflicts.yaml",
"label": "Conflits diagnostiques",
"description": "Exclusions mutuelles et incompatibilités entre codes CIM-10.",
"structure": "conflicts",
},
{
"id": "demographic_rules",
"path": CONFIG_DIR / "demographic_rules.yaml",
"label": "Règles démographiques",
"description": "Vérification cohérence âge/sexe avec les diagnostics codés.",
"structure": "generic",
},
{
"id": "temporal_rules",
"path": CONFIG_DIR / "temporal_rules.yaml",
"label": "Règles temporelles",
"description": "Durée de séjour minimale/maximale pour certains diagnostics.",
"structure": "generic",
},
{
"id": "parcours_rules",
"path": CONFIG_DIR / "parcours_rules.yaml",
"label": "Règles de parcours",
"description": "Vérification de la cohérence du parcours patient.",
"structure": "generic",
},
{
"id": "procedure_diagnosis_rules",
"path": CONFIG_DIR / "procedure_diagnosis_rules.yaml",
"label": "Règles actes-diagnostics",
"description": "Cohérence entre actes CCAM et diagnostics CIM-10.",
"structure": "generic",
},
{
"id": "completude_rules",
"path": CONFIG_DIR / "completude_rules.yaml",
"label": "Règles de complétude",
"description": "Vérification que le dossier contient les éléments requis pour le codage.",
"structure": "generic",
},
{
"id": "router",
"path": RULES_DIR / "router.yaml",
"label": "Routeur de règles",
"description": "Activation conditionnelle de packs selon le contenu du dossier.",
"structure": "generic",
},
{
"id": "enabled",
"path": RULES_DIR / "enabled.yaml",
"label": "Overlays actifs",
"description": "Sélection de spécialité, site, et overlays additionnels.",
"structure": "generic",
},
]
def list_rule_files() -> list[dict]:
"""Retourne la liste des fichiers de règles avec métadonnées."""
result = []
for rf in RULE_FILES:
path = rf["path"]
info = {**rf, "exists": path.exists(), "size": 0, "rules_count": 0}
if path.exists():
info["size"] = path.stat().st_size
try:
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
info["rules_count"] = _count_rules(data, rf["structure"])
except Exception:
pass
result.append(info)
return result
def load_rule_file(file_id: str) -> dict:
"""Charge un fichier de règles YAML complet."""
rf = _find_file(file_id)
if not rf["path"].exists():
return {}
return yaml.safe_load(rf["path"].read_text(encoding="utf-8")) or {}
def save_rule_file(file_id: str, data: dict) -> None:
"""Sauvegarde un fichier de règles YAML."""
rf = _find_file(file_id)
rf["path"].write_text(
yaml.dump(data, default_flow_style=False, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
logger.info("Fichier de règles sauvegardé : %s", rf["path"])
def toggle_rule(file_id: str, rule_path: str, enabled: bool) -> dict:
"""Active/désactive une règle identifiée par son chemin dans le YAML.
rule_path : chemin pointé séparé par des '.' (ex: 'packs.vetos_core.rules.VETO-02')
"""
data = load_rule_file(file_id)
_set_nested(data, rule_path + ".enabled", enabled)
save_rule_file(file_id, data)
return data
def update_rule_field(file_id: str, rule_path: str, field: str, value) -> dict:
"""Met à jour un champ d'une règle."""
data = load_rule_file(file_id)
_set_nested(data, rule_path + "." + field, value)
save_rule_file(file_id, data)
return data
def add_rule(file_id: str, parent_path: str, rule_id: str, rule_data: dict) -> dict:
"""Ajoute une nouvelle règle sous parent_path."""
data = load_rule_file(file_id)
parent = _get_nested(data, parent_path)
if not isinstance(parent, dict):
raise ValueError(f"Chemin parent introuvable : {parent_path}")
if rule_id in parent:
raise ValueError(f"Règle '{rule_id}' existe déjà")
parent[rule_id] = rule_data
save_rule_file(file_id, data)
return data
def delete_rule(file_id: str, parent_path: str, rule_id: str) -> dict:
"""Supprime une règle."""
data = load_rule_file(file_id)
parent = _get_nested(data, parent_path)
if not isinstance(parent, dict) or rule_id not in parent:
raise ValueError(f"Règle '{rule_id}' introuvable dans '{parent_path}'")
del parent[rule_id]
save_rule_file(file_id, data)
return data
# ---------------------------------------------------------------------------
# Helpers internes
# ---------------------------------------------------------------------------
def _find_file(file_id: str) -> dict:
for rf in RULE_FILES:
if rf["id"] == file_id:
return rf
raise ValueError(f"Fichier de règles inconnu : {file_id}")
def _count_rules(data: dict, structure: str) -> int:
if structure == "packs":
count = 0
for pack in (data.get("packs") or {}).values():
count += len(pack.get("rules") or {})
return count
if structure == "flat_rules":
return len(data.get("rules") or {})
if structure == "conflicts":
return len(data.get("mutual_exclusions") or []) + len(data.get("incompatibilities") or [])
# generic: count top-level keys that look like rule containers
count = 0
for v in data.values():
if isinstance(v, dict):
count += len(v)
elif isinstance(v, list):
count += len(v)
return count
def _get_nested(data: dict, path: str):
"""Accède à un noeud du YAML via un chemin pointé."""
parts = path.split(".")
current = data
for p in parts:
if isinstance(current, dict) and p in current:
current = current[p]
else:
return None
return current
def _set_nested(data: dict, path: str, value) -> None:
"""Définit une valeur dans un dict imbriqué via un chemin pointé."""
parts = path.split(".")
current = data
for p in parts[:-1]:
if p not in current or not isinstance(current[p], dict):
current[p] = {}
current = current[p]
current[parts[-1]] = value

View File

@@ -0,0 +1,379 @@
{% extends "base.html" %}
{% block title %}Moteur de regles{% endblock %}
{% block sidebar %}
<div class="group-title">Admin</div>
<a href="/admin/rules" style="color:#60a5fa;font-weight:600;border-left-color:#3b82f6;">Moteur de regles</a>
<a href="/admin/referentiels">Referentiels RAG</a>
<a href="/dashboard">Dashboard</a>
<a href="/">Retour aux dossiers</a>
{% endblock %}
{% block content %}
<a class="back" href="/dashboard">&larr; Dashboard</a>
<h2 style="margin-top:1rem;">Moteur de regles metier</h2>
<p style="color:#64748b;font-size:0.85rem;margin-bottom:1.5rem;">
Gerez les regles du pipeline T2A : activez/desactivez, modifiez les parametres, ajoutez ou supprimez des regles.
Les modifications sont appliquees immediatement (fichiers YAML).
</p>
{# ---- Cartes synthese ---- #}
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0.75rem;margin-bottom:1.5rem;">
<div class="card" style="text-align:center;padding:1rem;">
<div style="font-size:0.7rem;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;font-weight:600;">Fichiers de regles</div>
<div style="font-size:1.5rem;font-weight:700;color:#3b82f6;margin-top:0.25rem;">{{ rule_files|length }}</div>
</div>
<div class="card" style="text-align:center;padding:1rem;">
<div style="font-size:0.7rem;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;font-weight:600;">Regles totales</div>
<div style="font-size:1.5rem;font-weight:700;color:#16a34a;margin-top:0.25rem;">{{ rule_files|sum(attribute='rules_count') }}</div>
</div>
<div class="card" style="text-align:center;padding:1rem;">
<div style="font-size:0.7rem;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;font-weight:600;">Type</div>
<div style="font-size:1.5rem;font-weight:700;color:#8b5cf6;margin-top:0.25rem;">YAML</div>
</div>
<div class="card" style="text-align:center;padding:1rem;">
<div style="font-size:0.7rem;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;font-weight:600;">Mode</div>
<div style="font-size:1.5rem;font-weight:700;color:#f59e0b;margin-top:0.25rem;">Strict</div>
</div>
</div>
{# ---- Tabs par fichier ---- #}
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:1rem;">
{% for rf in rule_files %}
<button class="tab-btn {% if loop.first %}active{% endif %}"
onclick="switchTab('{{rf.id}}')" id="tab-btn-{{rf.id}}"
style="padding:0.4rem 0.8rem;border:1px solid #e2e8f0;border-radius:0.375rem;
background:{% if loop.first %}#3b82f6{% else %}#fff{% endif %};
color:{% if loop.first %}#fff{% else %}#374151{% endif %};
cursor:pointer;font-size:0.8rem;font-weight:500;">
{{rf.label}}
<span style="margin-left:0.3rem;font-size:0.7rem;opacity:0.7;">({{rf.rules_count}})</span>
</button>
{% endfor %}
</div>
{# ---- Contenu par fichier ---- #}
{% for rf in rule_files %}
<div class="rule-tab" id="tab-{{rf.id}}" style="{% if not loop.first %}display:none;{% endif %}">
<div class="card" style="padding:1rem;margin-bottom:1rem;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<h3 style="margin:0 0 0.25rem;">{{rf.label}}</h3>
<p style="color:#64748b;font-size:0.8rem;margin:0;">{{rf.description}}</p>
</div>
<div style="font-size:0.75rem;color:#94a3b8;">
{{rf.rules_count}} regle(s)
{% if rf.exists %}
&middot; {{ (rf.size / 1024)|round(1) }} Ko
{% endif %}
</div>
</div>
</div>
{% if rf.exists and rf.data %}
{% if rf.structure == 'packs' %}
{# ---- Structure packs (base.yaml) ---- #}
{% for pack_name, pack in rf.data.get('packs', {}).items() %}
<div class="card" style="padding:1rem;margin-bottom:0.75rem;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;">
<h4 style="margin:0;font-size:0.95rem;">
Pack : <code style="background:#f1f5f9;padding:0.15rem 0.4rem;border-radius:0.25rem;">{{pack_name}}</code>
</h4>
<label style="display:flex;align-items:center;gap:0.4rem;cursor:pointer;font-size:0.8rem;">
<input type="checkbox" {% if pack.get('enabled', true) %}checked{% endif %}
onchange="toggleRule('{{rf.id}}', 'packs.{{pack_name}}', this.checked)"
style="width:1.1rem;height:1.1rem;accent-color:#3b82f6;">
Actif
</label>
</div>
<table class="rules-table" style="width:100%;font-size:0.8rem;border-collapse:collapse;">
<thead>
<tr style="border-bottom:1px solid #e2e8f0;">
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">ID</th>
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Description</th>
<th style="text-align:center;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Severite</th>
<th style="text-align:center;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Actif</th>
<th style="text-align:center;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Actions</th>
</tr>
</thead>
<tbody>
{% for rule_id, rule in (pack.get('rules') or {}).items() %}
<tr style="border-bottom:1px solid #f1f5f9;" id="row-{{rf.id}}-{{pack_name}}-{{rule_id}}">
<td style="padding:0.4rem 0.5rem;font-weight:600;font-family:monospace;">{{rule_id}}</td>
<td style="padding:0.4rem 0.5rem;">
<span class="desc-text" id="desc-{{rf.id}}-{{pack_name}}-{{rule_id}}">{{rule.get('description', '')}}</span>
</td>
<td style="padding:0.4rem 0.5rem;text-align:center;">
{% if rule.get('force_severity') %}
<span class="badge" style="background:#fee2e2;color:#dc2626;font-size:0.7rem;">{{rule.force_severity}}</span>
{% else %}
<span style="color:#94a3b8;font-size:0.75rem;"></span>
{% endif %}
</td>
<td style="text-align:center;padding:0.4rem 0.5rem;">
<input type="checkbox" {% if rule.get('enabled', true) %}checked{% endif %}
onchange="toggleRule('{{rf.id}}', 'packs.{{pack_name}}.rules.{{rule_id}}', this.checked)"
style="width:1rem;height:1rem;accent-color:#3b82f6;cursor:pointer;">
</td>
<td style="text-align:center;padding:0.4rem 0.5rem;">
<button onclick="deleteRule('{{rf.id}}', 'packs.{{pack_name}}.rules', '{{rule_id}}')"
style="border:none;background:none;color:#dc2626;cursor:pointer;font-size:0.85rem;"
title="Supprimer">&#x2715;</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div style="margin-top:0.5rem;">
<button onclick="showAddRule('{{rf.id}}', 'packs.{{pack_name}}.rules')"
style="font-size:0.75rem;padding:0.3rem 0.6rem;border:1px dashed #94a3b8;
border-radius:0.25rem;background:none;color:#64748b;cursor:pointer;">
+ Ajouter une regle
</button>
</div>
</div>
{% endfor %}
{% elif rf.structure == 'flat_rules' %}
{# ---- Structure rules plates (bio_rules.yaml) ---- #}
<div class="card" style="padding:1rem;">
{% if rf.data.get('missing_evidence') %}
<div style="margin-bottom:0.75rem;padding:0.5rem;background:#f8fafc;border-radius:0.25rem;">
<span style="font-weight:600;font-size:0.8rem;">Preuve manquante :</span>
<span style="font-size:0.8rem;">veto={{rf.data.missing_evidence.get('veto', '?')}},
severite={{rf.data.missing_evidence.get('severity', '?')}},
penalite={{rf.data.missing_evidence.get('score_penalty', '?')}}</span>
<label style="float:right;font-size:0.8rem;cursor:pointer;">
<input type="checkbox" {% if rf.data.missing_evidence.get('enabled', true) %}checked{% endif %}
onchange="toggleRule('{{rf.id}}', 'missing_evidence', this.checked)"
style="accent-color:#3b82f6;">
Actif
</label>
</div>
{% endif %}
<table class="rules-table" style="width:100%;font-size:0.8rem;border-collapse:collapse;">
<thead>
<tr style="border-bottom:1px solid #e2e8f0;">
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Regle</th>
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Codes CIM-10</th>
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Analyte</th>
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Seuil</th>
<th style="text-align:center;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Actif</th>
<th style="text-align:center;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Actions</th>
</tr>
</thead>
<tbody>
{% for rule_id, rule in (rf.data.get('rules') or {}).items() %}
<tr style="border-bottom:1px solid #f1f5f9;">
<td style="padding:0.4rem 0.5rem;font-weight:600;font-family:monospace;">{{rule_id}}</td>
<td style="padding:0.4rem 0.5rem;"><code>{{(rule.get('codes') or [])|join(', ')}}</code></td>
<td style="padding:0.4rem 0.5rem;">{{rule.get('analyte', '')}}</td>
<td style="padding:0.4rem 0.5rem;">
{{rule.get('threshold_type', '')}}
{% if rule.get('message') %}<span style="color:#94a3b8;"> ({{rule.message}})</span>{% endif %}
</td>
<td style="text-align:center;padding:0.4rem 0.5rem;">
<input type="checkbox" {% if rule.get('enabled', true) %}checked{% endif %}
onchange="toggleRule('{{rf.id}}', 'rules.{{rule_id}}', this.checked)"
style="width:1rem;height:1rem;accent-color:#3b82f6;cursor:pointer;">
</td>
<td style="text-align:center;padding:0.4rem 0.5rem;">
<button onclick="deleteRule('{{rf.id}}', 'rules', '{{rule_id}}')"
style="border:none;background:none;color:#dc2626;cursor:pointer;font-size:0.85rem;"
title="Supprimer">&#x2715;</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div style="margin-top:0.5rem;">
<button onclick="showAddRule('{{rf.id}}', 'rules')"
style="font-size:0.75rem;padding:0.3rem 0.6rem;border:1px dashed #94a3b8;
border-radius:0.25rem;background:none;color:#64748b;cursor:pointer;">
+ Ajouter une regle
</button>
</div>
</div>
{% elif rf.structure == 'conflicts' %}
{# ---- Conflits diagnostiques ---- #}
<div class="card" style="padding:1rem;">
<h4 style="margin:0 0 0.5rem;font-size:0.9rem;">Exclusions mutuelles</h4>
<table class="rules-table" style="width:100%;font-size:0.8rem;border-collapse:collapse;margin-bottom:1rem;">
<thead>
<tr style="border-bottom:1px solid #e2e8f0;">
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Nom</th>
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Codes</th>
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Message</th>
<th style="text-align:center;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Severite</th>
</tr>
</thead>
<tbody>
{% for excl in (rf.data.get('mutual_exclusions') or []) %}
<tr style="border-bottom:1px solid #f1f5f9;">
<td style="padding:0.4rem 0.5rem;font-weight:600;">{{excl.get('name', '')}}</td>
<td style="padding:0.4rem 0.5rem;"><code>{{(excl.get('codes') or [])|join(', ')}}</code></td>
<td style="padding:0.4rem 0.5rem;font-size:0.75rem;">{{excl.get('message', '')}}</td>
<td style="text-align:center;padding:0.4rem 0.5rem;">
{% set sev = excl.get('severity', 'MEDIUM') %}
<span class="badge" style="background:{% if sev == 'HARD' %}#fee2e2{% else %}#fef3c7{% endif %};
color:{% if sev == 'HARD' %}#dc2626{% else %}#92400e{% endif %};font-size:0.7rem;">{{sev}}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<h4 style="margin:0 0 0.5rem;font-size:0.9rem;">Incompatibilites</h4>
<table class="rules-table" style="width:100%;font-size:0.8rem;border-collapse:collapse;">
<thead>
<tr style="border-bottom:1px solid #e2e8f0;">
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Codes</th>
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Ref ATIH</th>
<th style="text-align:left;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Message</th>
<th style="text-align:center;padding:0.3rem 0.5rem;color:#64748b;font-weight:600;">Severite</th>
</tr>
</thead>
<tbody>
{% for inc in (rf.data.get('incompatibilities') or []) %}
<tr style="border-bottom:1px solid #f1f5f9;">
<td style="padding:0.4rem 0.5rem;"><code>{{(inc.get('pair') or [])|join(', ')}}</code></td>
<td style="padding:0.4rem 0.5rem;font-size:0.75rem;">{{inc.get('atih_ref', '')}}</td>
<td style="padding:0.4rem 0.5rem;font-size:0.75rem;">{{inc.get('message', '')}}</td>
<td style="text-align:center;padding:0.4rem 0.5rem;">
{% set sev = inc.get('severity', 'MEDIUM') %}
<span class="badge" style="background:{% if sev == 'HARD' %}#fee2e2{% else %}#fef3c7{% endif %};
color:{% if sev == 'HARD' %}#dc2626{% else %}#92400e{% endif %};font-size:0.7rem;">{{sev}}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
{# ---- Structure generique (YAML brut) ---- #}
<div class="card" style="padding:1rem;">
<pre style="background:#f8fafc;padding:0.75rem;border-radius:0.375rem;font-size:0.75rem;
overflow-x:auto;max-height:500px;margin:0;white-space:pre-wrap;">{{ rf.data|tojson(indent=2) }}</pre>
</div>
{% endif %}
{% else %}
<div class="card" style="padding:1rem;color:#94a3b8;text-align:center;">
Fichier non trouve : <code>{{rf.path}}</code>
</div>
{% endif %}
</div>
{% endfor %}
{# ---- Modal ajout regle ---- #}
<div id="add-rule-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:1000;
display:none;align-items:center;justify-content:center;">
<div style="background:#fff;border-radius:0.5rem;padding:1.5rem;width:400px;max-width:90vw;box-shadow:0 25px 50px rgba(0,0,0,0.2);">
<h3 style="margin:0 0 1rem;">Ajouter une regle</h3>
<input id="add-rule-id" placeholder="Identifiant (ex: VETO-99)"
style="width:100%;padding:0.5rem;border:1px solid #d1d5db;border-radius:0.25rem;margin-bottom:0.5rem;box-sizing:border-box;">
<input id="add-rule-desc" placeholder="Description"
style="width:100%;padding:0.5rem;border:1px solid #d1d5db;border-radius:0.25rem;margin-bottom:0.5rem;box-sizing:border-box;">
<div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:1rem;">
<button onclick="closeAddRule()"
style="padding:0.4rem 1rem;border:1px solid #d1d5db;border-radius:0.25rem;background:#fff;cursor:pointer;">Annuler</button>
<button onclick="submitAddRule()"
style="padding:0.4rem 1rem;border:none;border-radius:0.25rem;background:#3b82f6;color:#fff;cursor:pointer;">Ajouter</button>
</div>
</div>
</div>
{# ---- Toast notifications ---- #}
<div id="toast" style="display:none;position:fixed;bottom:2rem;right:2rem;padding:0.75rem 1.25rem;
border-radius:0.375rem;color:#fff;font-size:0.85rem;z-index:1001;box-shadow:0 4px 12px rgba(0,0,0,0.15);"></div>
<script>
function switchTab(fileId) {
document.querySelectorAll('.rule-tab').forEach(t => t.style.display = 'none');
document.querySelectorAll('.tab-btn').forEach(b => {
b.style.background = '#fff';
b.style.color = '#374151';
});
const tab = document.getElementById('tab-' + fileId);
const btn = document.getElementById('tab-btn-' + fileId);
if (tab) tab.style.display = 'block';
if (btn) { btn.style.background = '#3b82f6'; btn.style.color = '#fff'; }
}
function showToast(message, success) {
const toast = document.getElementById('toast');
toast.style.background = success ? '#16a34a' : '#dc2626';
toast.textContent = message;
toast.style.display = 'block';
setTimeout(() => { toast.style.display = 'none'; }, 3000);
}
function toggleRule(fileId, rulePath, enabled) {
fetch('/api/rules/' + fileId + '/toggle', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({rule_path: rulePath, enabled: enabled})
})
.then(r => r.json())
.then(data => {
if (data.ok) showToast('Regle ' + (enabled ? 'activee' : 'desactivee'), true);
else showToast('Erreur : ' + data.error, false);
})
.catch(err => showToast('Erreur reseau', false));
}
function deleteRule(fileId, parentPath, ruleId) {
if (!confirm('Supprimer la regle "' + ruleId + '" ?')) return;
fetch('/api/rules/' + fileId + '/delete', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({parent_path: parentPath, rule_id: ruleId})
})
.then(r => r.json())
.then(data => {
if (data.ok) {
showToast('Regle supprimee', true);
setTimeout(() => location.reload(), 500);
} else showToast('Erreur : ' + data.error, false);
})
.catch(err => showToast('Erreur reseau', false));
}
let _addFileId = '', _addParentPath = '';
function showAddRule(fileId, parentPath) {
_addFileId = fileId;
_addParentPath = parentPath;
document.getElementById('add-rule-id').value = '';
document.getElementById('add-rule-desc').value = '';
document.getElementById('add-rule-modal').style.display = 'flex';
}
function closeAddRule() {
document.getElementById('add-rule-modal').style.display = 'none';
}
function submitAddRule() {
const ruleId = document.getElementById('add-rule-id').value.trim();
const desc = document.getElementById('add-rule-desc').value.trim();
if (!ruleId) { alert('Identifiant requis'); return; }
fetch('/api/rules/' + _addFileId + '/add', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
parent_path: _addParentPath,
rule_id: ruleId,
rule_data: {enabled: true, description: desc}
})
})
.then(r => r.json())
.then(data => {
if (data.ok) {
showToast('Regle ajoutee', true);
closeAddRule();
setTimeout(() => location.reload(), 500);
} else showToast('Erreur : ' + data.error, false);
})
.catch(err => showToast('Erreur reseau', false));
}
</script>
{% endblock %}

View File

@@ -19,28 +19,32 @@ from src.config import (
Traitement, Traitement,
) )
from src.control.cpam_response import ( from src.control.cpam_response import (
_assess_dossier_strength,
_build_bio_confrontation,
_build_bio_summary,
_build_correction_prompt, _build_correction_prompt,
_build_cpam_prompt, _build_cpam_prompt,
_build_tagged_context, _build_tagged_context,
_BIO_THRESHOLDS,
_check_das_bio_coherence,
_extraction_pass, _extraction_pass,
_format_response, _format_response,
_fuzzy_match_ref,
_get_cim10_definitions,
_get_code_label,
_sanitize_unauthorized_codes,
_search_rag_for_control, _search_rag_for_control,
_validate_adversarial,
_validate_codes_in_response, _validate_codes_in_response,
_validate_grounding, _validate_grounding,
_validate_references, _validate_references,
_assess_quality_tier,
generate_cpam_response, generate_cpam_response,
) )
from src.control.cpam_context import (
_assess_dossier_strength,
_build_bio_confrontation,
_build_bio_summary,
_BIO_THRESHOLDS,
_check_das_bio_coherence,
_get_cim10_definitions,
_get_code_label,
)
from src.control.cpam_validation import (
_assess_quality_tier,
_fuzzy_match_ref,
_sanitize_unauthorized_codes,
_validate_adversarial,
)
def _make_dossier() -> DossierMedical: def _make_dossier() -> DossierMedical:

View File

@@ -176,8 +176,8 @@ class TestBioNormesInContext:
assert "[N: min-max]" in prompt assert "[N: min-max]" in prompt
def test_bio_normals_exported(self): def test_bio_normals_exported(self):
"""BIO_NORMALS est bien exporté depuis cim10_extractor.""" """BIO_NORMALS est bien exporté depuis bio_normals."""
from src.medical.cim10_extractor import BIO_NORMALS from src.medical.bio_normals import BIO_NORMALS
assert "Créatinine" in BIO_NORMALS assert "Créatinine" in BIO_NORMALS
assert BIO_NORMALS["Créatinine"] == (50, 120) assert BIO_NORMALS["Créatinine"] == (50, 120)

View File

@@ -3,12 +3,10 @@
import pytest import pytest
from src.config import DossierMedical, Diagnostic, Antecedent, Complication from src.config import DossierMedical, Diagnostic, Antecedent, Complication
from src.medical.cim10_extractor import ( from src.medical.cim10_extractor import extract_medical_info
extract_medical_info, from src.medical.diagnostic_extraction import _lookup_cim10
_lookup_cim10, from src.medical.bio_normals import _is_abnormal
_is_abnormal, from src.medical.cim10_extractor import _is_valid_antecedent
_is_valid_antecedent,
)
from src.medical.cim10_dict import normalize_text, load_dict, lookup, reset_cache from src.medical.cim10_dict import normalize_text, load_dict, lookup, reset_cache
from src.extraction.document_classifier import classify, classify_with_confidence from src.extraction.document_classifier import classify, classify_with_confidence

View File

@@ -6,7 +6,8 @@ import pytest
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from src.viewer.app import create_app, compute_group_stats, severity_badge, format_duration, format_cpam_text from src.viewer.app import create_app
from src.viewer.helpers import compute_group_stats, severity_badge, format_duration, format_cpam_text
from src.viewer.pdf_redactor import load_entities_from_report, redact_pdf, highlight_text from src.viewer.pdf_redactor import load_entities_from_report, redact_pdf, highlight_text
from src.config import DossierMedical, Diagnostic, ActeCCAM from src.config import DossierMedical, Diagnostic, ActeCCAM