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:
121
README.md
Normal file
121
README.md
Normal 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`.
|
||||||
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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__)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
1160
src/viewer/app.py
1160
src/viewer/app.py
File diff suppressed because it is too large
Load Diff
105
src/viewer/bp_rules.py
Normal file
105
src/viewer/bp_rules.py
Normal 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
702
src/viewer/helpers.py
Normal 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
218
src/viewer/rules_manager.py
Normal 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
|
||||||
379
src/viewer/templates/admin_rules.html
Normal file
379
src/viewer/templates/admin_rules.html
Normal 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">← 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 %}
|
||||||
|
· {{ (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">✕</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">✕</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 %}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user