chore: sauvegarde état courant avant merge des branches teammates
Modifications en cours : pipeline médical (cim10_extractor, dp_finalizer, dp_selector, fusion, rag_search), viewer (helpers, detail.html), cache ollama et référentiels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -211,6 +211,9 @@ def extract_medical_info(
|
||||
# Post-processing : calcul DFG et détection IRC non codée
|
||||
_check_dfg_irc(dossier)
|
||||
|
||||
# Post-processing : auto-codage anomalies biologiques évidentes
|
||||
_auto_code_bio_anomalies(dossier)
|
||||
|
||||
# Post-processing : détection erreurs fréquentes (intuition DIM senior)
|
||||
try:
|
||||
from .dim_senior import check_common_mistakes
|
||||
@@ -247,13 +250,15 @@ def extract_medical_info(
|
||||
|
||||
|
||||
def _check_dfg_irc(dossier: DossierMedical) -> None:
|
||||
"""Calcule le DFG si créatinine disponible et alerte si IRC non codée."""
|
||||
"""Calcule le DFG si créatinine disponible et alerte si IRC non codée.
|
||||
|
||||
Si âge/sexe manquants (données anonymisées), détecte quand même l'IRA
|
||||
à partir d'une créatinine très élevée (> 150 µmol/L).
|
||||
"""
|
||||
from .bio_normals import compute_dfg, stade_irc
|
||||
|
||||
age = dossier.sejour.age if dossier.sejour else None
|
||||
sexe = dossier.sejour.sexe if dossier.sejour else None
|
||||
if not age or not sexe or age < 18:
|
||||
return
|
||||
|
||||
# Trouver la créatinine
|
||||
creat_val = None
|
||||
@@ -264,6 +269,33 @@ def _check_dfg_irc(dossier: DossierMedical) -> None:
|
||||
if creat_val is None:
|
||||
return
|
||||
|
||||
# Codes rénaux déjà présents
|
||||
all_codes = set()
|
||||
if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion:
|
||||
all_codes.add(dossier.diagnostic_principal.cim10_suggestion)
|
||||
for das in dossier.diagnostics_associes:
|
||||
if das.cim10_suggestion:
|
||||
all_codes.add(das.cim10_suggestion)
|
||||
|
||||
# Si âge/sexe manquants → pas de DFG, mais auto-code IRA si créatinine très haute
|
||||
if not age or not sexe or age < 18:
|
||||
if creat_val > 150:
|
||||
n17 = any(c.startswith("N17") for c in all_codes)
|
||||
n18 = any(c.startswith("N18") for c in all_codes)
|
||||
n19 = any(c.startswith("N19") for c in all_codes)
|
||||
if not n17 and not n18 and not n19:
|
||||
dossier.diagnostics_associes.append(Diagnostic(
|
||||
texte="Insuffisance rénale aiguë",
|
||||
cim10_suggestion="N17.9",
|
||||
cim10_confidence="medium",
|
||||
justification=f"Créatinine {creat_val} µmol/L > 150 (âge inconnu, DFG non calculable)",
|
||||
source="auto_bio",
|
||||
))
|
||||
dossier.alertes_codage.append(
|
||||
f"AUTO-CODE IRA: N17.9 ajouté — Créatinine {creat_val} µmol/L > 150"
|
||||
)
|
||||
return
|
||||
|
||||
dfg = compute_dfg(creat_val, age, sexe)
|
||||
if dfg is None:
|
||||
return
|
||||
@@ -273,22 +305,79 @@ def _check_dfg_irc(dossier: DossierMedical) -> None:
|
||||
# Vérifier si IRC codée alors que DFG le justifie
|
||||
if dfg < 60:
|
||||
stade, code_attendu = stade_irc(dfg)
|
||||
# Chercher si un N18.x est déjà codé
|
||||
irc_coded = False
|
||||
all_codes = set()
|
||||
if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion:
|
||||
all_codes.add(dossier.diagnostic_principal.cim10_suggestion)
|
||||
for das in dossier.diagnostics_associes:
|
||||
if das.cim10_suggestion:
|
||||
all_codes.add(das.cim10_suggestion)
|
||||
irc_coded = any(c.startswith("N18") for c in all_codes)
|
||||
|
||||
if not irc_coded:
|
||||
dossier.diagnostics_associes.append(Diagnostic(
|
||||
texte=f"Insuffisance rénale chronique stade {stade}",
|
||||
cim10_suggestion=code_attendu,
|
||||
cim10_confidence="high",
|
||||
justification=f"DFG estimé {dfg} mL/min (créatinine {creat_val} µmol/L, {sexe} {age} ans)",
|
||||
source="auto_bio",
|
||||
))
|
||||
dossier.alertes_codage.append(
|
||||
f"DFG estimé {dfg} mL/min (stade {stade}) — IRC ({code_attendu}) "
|
||||
f"non codée. Créatinine {creat_val} µmol/L, {sexe} {age} ans."
|
||||
f"AUTO-CODE IRC: {code_attendu} ajouté (DFG {dfg} mL/min, stade {stade}) — "
|
||||
f"Créatinine {creat_val} µmol/L, {sexe} {age} ans."
|
||||
)
|
||||
dossier.quality_flags["irc_auto_codee"] = code_attendu
|
||||
|
||||
|
||||
# Mapping : (analyte, direction) → (code CIM-10, texte, seuil_critique)
|
||||
# Seuil critique = valeur à partir de laquelle le codage est quasi-certain
|
||||
_BIO_AUTO_CODE: list[tuple[str, str, str, str, float | None]] = [
|
||||
# analyte, direction, code, texte, seuil_critique (None = toute anomalie)
|
||||
("Hémoglobine", "low", "D64.9", "Anémie", 10.0), # Hb < 10 g/dL
|
||||
("Potassium", "high", "E87.5", "Hyperkaliémie", 5.5), # K > 5.5 mmol/L
|
||||
("Potassium", "low", "E87.6", "Hypokaliémie", 3.0), # K < 3.0 mmol/L
|
||||
("Sodium", "low", "E87.1", "Hyponatrémie", 130.0), # Na < 130 mmol/L
|
||||
("Plaquettes", "low", "D69.6", "Thrombopénie", 100.0), # Plq < 100 G/L
|
||||
]
|
||||
|
||||
|
||||
def _auto_code_bio_anomalies(dossier: DossierMedical) -> None:
|
||||
"""Auto-code des DAS quand des anomalies biologiques franches ne sont pas codées."""
|
||||
from .bio_normals import _is_abnormal
|
||||
|
||||
all_codes = set()
|
||||
if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion:
|
||||
all_codes.add(dossier.diagnostic_principal.cim10_suggestion)
|
||||
for das in dossier.diagnostics_associes:
|
||||
if das.cim10_suggestion:
|
||||
all_codes.add(das.cim10_suggestion)
|
||||
|
||||
age = dossier.sejour.age if dossier.sejour else None
|
||||
sexe = dossier.sejour.sexe if dossier.sejour else None
|
||||
|
||||
for bio in dossier.biologie_cle:
|
||||
if not bio.valeur_num:
|
||||
continue
|
||||
is_abn = _is_abnormal(bio.test, bio.valeur, age, sexe)
|
||||
if not is_abn:
|
||||
continue
|
||||
|
||||
for analyte, direction, code, texte, seuil in _BIO_AUTO_CODE:
|
||||
if bio.test != analyte:
|
||||
continue
|
||||
# Vérifier la direction (high/low)
|
||||
if direction == "low" and seuil is not None and bio.valeur_num >= seuil:
|
||||
continue
|
||||
if direction == "high" and seuil is not None and bio.valeur_num <= seuil:
|
||||
continue
|
||||
# Vérifier que le code n'est pas déjà codé (exact ou famille 3 chars)
|
||||
if any(c.startswith(code[:3]) for c in all_codes):
|
||||
continue
|
||||
# Auto-coder
|
||||
dossier.diagnostics_associes.append(Diagnostic(
|
||||
texte=texte,
|
||||
cim10_suggestion=code,
|
||||
cim10_confidence="high",
|
||||
justification=f"{bio.test} = {bio.valeur} (seuil auto-code: {seuil})",
|
||||
source="auto_bio",
|
||||
))
|
||||
all_codes.add(code)
|
||||
dossier.alertes_codage.append(
|
||||
f"AUTO-CODE BIO: {code} ({texte}) ajouté — {bio.test} = {bio.valeur}"
|
||||
)
|
||||
dossier.quality_flags["irc_non_codee"] = code_attendu
|
||||
|
||||
|
||||
def _extract_das_llm(text: str, dossier: DossierMedical) -> None:
|
||||
|
||||
@@ -320,6 +320,38 @@ def finalize_dp(dossier: DossierMedical) -> DossierMedical:
|
||||
# ── 3. Arbitrage ───────────────────────────────────────────────
|
||||
dp_final, flags, alertes = decide_dp_final(trackare_dp, crh_dp)
|
||||
|
||||
# ── 3b. R6 — Z-code non whitelisté en DP : promotion DAS ──────
|
||||
final_code = (dp_final.chosen_code or "").upper()
|
||||
if (
|
||||
final_code.startswith("Z")
|
||||
and _family3(final_code) not in _Z_CODE_DP_WHITELIST
|
||||
and dossier.diagnostics_associes
|
||||
):
|
||||
best_das = None
|
||||
for das in dossier.diagnostics_associes:
|
||||
c = (das.cim10_suggestion or "").upper()
|
||||
if not c or c.startswith("R") or c.startswith("Z"):
|
||||
continue
|
||||
if das.cim10_confidence in ("high", "medium"):
|
||||
best_das = das
|
||||
break
|
||||
if best_das:
|
||||
old_code = dp_final.chosen_code
|
||||
dp_final.chosen_code = best_das.cim10_suggestion
|
||||
dp_final.chosen_term = best_das.texte
|
||||
dp_final.verdict = "REVIEW"
|
||||
dp_final.confidence = "medium"
|
||||
dp_final.evidence.append(
|
||||
f"R6 — Z-code {old_code} remplacé par DAS {best_das.cim10_suggestion} "
|
||||
f"({best_das.texte[:40]})"
|
||||
)
|
||||
dp_final.reason = (dp_final.reason or "") + " | R6 Z-code→DAS"
|
||||
flags["z_code_replaced_by_das"] = True
|
||||
alertes.append(
|
||||
f"Z-code {old_code} inadapté en DP → remplacé par "
|
||||
f"{best_das.cim10_suggestion} ({best_das.texte[:40]})"
|
||||
)
|
||||
|
||||
# ── 4. Écrire les résultats ────────────────────────────────────
|
||||
dossier.dp_final = dp_final
|
||||
|
||||
|
||||
@@ -256,15 +256,15 @@ def score_candidates(
|
||||
score += _diag_section_bonus
|
||||
details["diag_section_bonus"] = _diag_section_bonus
|
||||
|
||||
# 5. Malus comorbidité chronique
|
||||
# 5. Malus comorbidité chronique (réduit : les comorbidités sont parfois DP légitimes)
|
||||
if c.is_comorbidity_like:
|
||||
score -= 3
|
||||
details["comorbidity_malus"] = -3
|
||||
score -= 1.5
|
||||
details["comorbidity_malus"] = -1.5
|
||||
|
||||
# 6. Malus symptôme (R-code)
|
||||
# 6. Malus symptôme (R-code) — renforcé : un symptôme est rarement le DP
|
||||
if c.is_symptom_like:
|
||||
score -= 2
|
||||
details["symptom_malus"] = -2
|
||||
score -= 3
|
||||
details["symptom_malus"] = -3
|
||||
|
||||
# 7. Malus acte-seul
|
||||
if c.is_act_only:
|
||||
@@ -579,6 +579,23 @@ def select_dp(
|
||||
reason="Aucun candidat DP identifié",
|
||||
)
|
||||
|
||||
# Garde-fou R-code : si le top-1 est un R-code et qu'il existe un candidat
|
||||
# non-symptôme ET non-comorbidité, forcer sa promotion
|
||||
if (len(candidates) >= 2
|
||||
and _is_symptom_like(candidates[0].code)):
|
||||
best_diag = next(
|
||||
(c for c in candidates
|
||||
if not _is_symptom_like(c.code) and not _is_comorbidity_like(c.code)),
|
||||
None,
|
||||
)
|
||||
if best_diag and best_diag is not candidates[0]:
|
||||
logger.info(
|
||||
"NUKE-3: R-code %s rétrogradé, promotion de %s (%s)",
|
||||
candidates[0].code, best_diag.term, best_diag.code,
|
||||
)
|
||||
candidates.remove(best_diag)
|
||||
candidates.insert(0, best_diag)
|
||||
|
||||
# Candidat unique → CONFIRMED (sous réserve hardening A2)
|
||||
if len(candidates) == 1:
|
||||
c = candidates[0]
|
||||
|
||||
@@ -83,10 +83,38 @@ def get_pipeline():
|
||||
def analyze(text: str) -> EdsnlpResult:
|
||||
"""Analyse un texte médical avec edsnlp.
|
||||
|
||||
Essaie le serveur distant d'abord, puis fallback local.
|
||||
Retourne les entités CIM-10, médicaments et dates détectées.
|
||||
"""
|
||||
result = EdsnlpResult()
|
||||
|
||||
# Essayer le serveur distant d'abord
|
||||
try:
|
||||
from .remote_embed import ner_remote
|
||||
remote = ner_remote(text)
|
||||
if remote is not None and "error" not in remote:
|
||||
for ent in remote.get("cim10", []):
|
||||
result.cim10_entities.append(CIM10Entity(
|
||||
texte=ent["text"], code=ent["code"],
|
||||
negation=ent.get("negation", False),
|
||||
hypothese=ent.get("hypothesis", False),
|
||||
))
|
||||
for ent in remote.get("drugs", []):
|
||||
result.drug_entities.append(DrugEntity(
|
||||
texte=ent["text"], code_atc=ent.get("code_atc"),
|
||||
negation=ent.get("negation", False),
|
||||
))
|
||||
for ent in remote.get("dates", []):
|
||||
result.date_entities.append(DateEntity(
|
||||
texte=ent["text"], value=ent.get("value"),
|
||||
))
|
||||
logger.debug("edsnlp distant: %d CIM-10, %d drugs, %.0fms",
|
||||
len(result.cim10_entities), len(result.drug_entities),
|
||||
remote.get("time_ms", 0))
|
||||
return result
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if not is_available():
|
||||
return result
|
||||
|
||||
|
||||
@@ -35,22 +35,47 @@ def _cim10_specificity(code: str | None) -> int:
|
||||
return len(code.replace(".", ""))
|
||||
|
||||
|
||||
def _dp_sort_score(diag: Diagnostic, doc_type: str) -> tuple[int, int, int, int]:
|
||||
"""Score de tri pour sélection DP fusionné. Plus petit = meilleur.
|
||||
|
||||
Priorité :
|
||||
1. Pénalité Z/R codes (vrais diagnostics d'abord)
|
||||
2. Bonus trackare (codage DIM établissement)
|
||||
3. Spécificité CIM-10 décroissante
|
||||
4. Confiance (high > medium > low)
|
||||
"""
|
||||
code = (diag.cim10_suggestion or "").upper()
|
||||
# Pénalité Z/R : 0=diagnostic, 1=R-code, 2=Z-code
|
||||
zr_penalty = 0
|
||||
if code.startswith("Z"):
|
||||
zr_penalty = 2
|
||||
elif code.startswith("R"):
|
||||
zr_penalty = 1
|
||||
# Bonus trackare : 0=trackare, 1=autre
|
||||
trackare_bonus = 0 if doc_type == "trackare" else 1
|
||||
# Spécificité inverse (négatif pour tri ascendant)
|
||||
spec = -_cim10_specificity(code)
|
||||
# Confiance
|
||||
conf_order = {"high": 0, "medium": 1, "low": 2}
|
||||
conf = conf_order.get(diag.cim10_confidence or "", 3)
|
||||
return (zr_penalty, spec, conf, trackare_bonus)
|
||||
|
||||
|
||||
def _prefer_most_specific_dp(dossiers: list[DossierMedical]) -> Diagnostic | None:
|
||||
"""Sélectionne le DP le plus spécifique parmi tous les dossiers."""
|
||||
candidates: list[tuple[Diagnostic, int]] = []
|
||||
"""Sélectionne le meilleur DP parmi tous les dossiers.
|
||||
|
||||
Préfère diagnostics réels > R-codes > Z-codes, trackare > CRH,
|
||||
puis spécificité CIM-10 décroissante.
|
||||
"""
|
||||
candidates: list[tuple[Diagnostic, str]] = []
|
||||
for d in dossiers:
|
||||
if d.diagnostic_principal:
|
||||
spec = _cim10_specificity(d.diagnostic_principal.cim10_suggestion)
|
||||
candidates.append((d.diagnostic_principal, spec))
|
||||
if d.diagnostic_principal and d.diagnostic_principal.cim10_suggestion:
|
||||
candidates.append((d.diagnostic_principal, d.document_type or ""))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
# Tri : spécificité décroissante, puis confiance (high > medium > low)
|
||||
conf_order = {"high": 0, "medium": 1, "low": 2}
|
||||
candidates.sort(
|
||||
key=lambda x: (-x[1], conf_order.get(x[0].cim10_confidence or "", 3))
|
||||
)
|
||||
candidates.sort(key=lambda x: _dp_sort_score(x[0], x[1]))
|
||||
return candidates[0][0]
|
||||
|
||||
|
||||
@@ -189,15 +214,56 @@ def merge_dossiers(dossiers: list[DossierMedical]) -> DossierMedical:
|
||||
merged.diagnostic_principal = _prefer_most_specific_dp(dossiers)
|
||||
|
||||
# Propager dp_selection depuis le dossier source du DP retenu
|
||||
from ..config import DPSelection
|
||||
if merged.diagnostic_principal:
|
||||
dp_code = merged.diagnostic_principal.cim10_suggestion
|
||||
for d in dossiers:
|
||||
if (d.diagnostic_principal
|
||||
and d.diagnostic_principal.cim10_suggestion == dp_code
|
||||
and d.dp_selection is not None):
|
||||
merged.dp_selection = d.dp_selection
|
||||
and d.diagnostic_principal.cim10_suggestion == dp_code):
|
||||
if d.dp_selection is not None:
|
||||
merged.dp_selection = d.dp_selection
|
||||
else:
|
||||
# Créer un dp_selection synthétique pour le finalizer
|
||||
merged.dp_selection = DPSelection(
|
||||
chosen_code=dp_code,
|
||||
chosen_term=d.diagnostic_principal.texte,
|
||||
verdict="CONFIRMED",
|
||||
confidence=d.diagnostic_principal.cim10_confidence or "medium",
|
||||
evidence=[f"Source: {d.document_type or 'fusion'}"],
|
||||
reason=f"DP {d.document_type or 'fusion'} (synthétique)",
|
||||
)
|
||||
break
|
||||
|
||||
# Propager les dp_selection trackare et CRH séparément pour le finalizer
|
||||
for d in dossiers:
|
||||
if d.document_type == "trackare":
|
||||
if merged.dp_trackare is None:
|
||||
if d.dp_selection is not None:
|
||||
merged.dp_trackare = d.dp_selection
|
||||
elif d.diagnostic_principal and d.diagnostic_principal.cim10_suggestion:
|
||||
# Créer un DPSelection synthétique depuis le DP trackare
|
||||
merged.dp_trackare = DPSelection(
|
||||
chosen_code=d.diagnostic_principal.cim10_suggestion,
|
||||
chosen_term=d.diagnostic_principal.texte,
|
||||
verdict="CONFIRMED",
|
||||
confidence=d.diagnostic_principal.cim10_confidence or "medium",
|
||||
evidence=["Source: Trackare (codage établissement)"],
|
||||
reason="DP Trackare (synthétique)",
|
||||
)
|
||||
else:
|
||||
if merged.dp_crh_only is None:
|
||||
if d.dp_selection is not None:
|
||||
merged.dp_crh_only = d.dp_selection
|
||||
elif d.diagnostic_principal and d.diagnostic_principal.cim10_suggestion:
|
||||
merged.dp_crh_only = DPSelection(
|
||||
chosen_code=d.diagnostic_principal.cim10_suggestion,
|
||||
chosen_term=d.diagnostic_principal.texte,
|
||||
verdict="CONFIRMED",
|
||||
confidence=d.diagnostic_principal.cim10_confidence or "medium",
|
||||
evidence=["Source: CRH (analyse pipeline)"],
|
||||
reason="DP CRH (synthétique)",
|
||||
)
|
||||
|
||||
# Collecter tous les DAS + DP non retenus comme DAS
|
||||
all_das: list[Diagnostic] = []
|
||||
for d in dossiers:
|
||||
|
||||
@@ -149,7 +149,7 @@ def parse_json_response(raw: str) -> dict | None:
|
||||
def call_ollama(
|
||||
prompt: str,
|
||||
temperature: float = 0.1,
|
||||
max_tokens: int = 2500,
|
||||
max_tokens: int = 4000,
|
||||
model: str | None = None,
|
||||
timeout: int | None = None,
|
||||
role: str | None = None,
|
||||
|
||||
@@ -122,11 +122,23 @@ def _rerank(query: str, results: list[dict], top_k: int) -> list[dict]:
|
||||
if not results:
|
||||
return results
|
||||
|
||||
reranker = _get_reranker()
|
||||
passages = [r.get("extrait", "") for r in results]
|
||||
|
||||
# Construire les paires (query, passage) pour le cross-encoder
|
||||
pairs = [(query, r.get("extrait", "")) for r in results]
|
||||
ce_scores = reranker.predict(pairs)
|
||||
# Essayer le serveur distant d'abord
|
||||
ce_scores = None
|
||||
try:
|
||||
from .remote_embed import rerank_remote
|
||||
remote_scores = rerank_remote(query, passages)
|
||||
if remote_scores is not None:
|
||||
ce_scores = remote_scores
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback local
|
||||
if ce_scores is None:
|
||||
reranker = _get_reranker()
|
||||
pairs = [(query, p) for p in passages]
|
||||
ce_scores = reranker.predict(pairs)
|
||||
|
||||
# Injecter le score cross-encoder et trier
|
||||
for r, ce_score in zip(results, ce_scores):
|
||||
@@ -138,10 +150,9 @@ def _rerank(query: str, results: list[dict], top_k: int) -> list[dict]:
|
||||
|
||||
|
||||
def _embed_cached(texts: list[str]) -> "numpy.ndarray":
|
||||
"""Calcule les embeddings avec cache. Retourne un array (N, dim)."""
|
||||
"""Calcule les embeddings avec cache. Essaie le serveur distant d'abord."""
|
||||
import numpy as np
|
||||
|
||||
model = _get_embed_model()
|
||||
results = [None] * len(texts)
|
||||
to_compute: list[tuple[int, str]] = []
|
||||
|
||||
@@ -155,8 +166,20 @@ def _embed_cached(texts: list[str]) -> "numpy.ndarray":
|
||||
|
||||
if to_compute:
|
||||
new_texts = [t for _, t in to_compute]
|
||||
new_vecs = model.encode(new_texts, normalize_embeddings=True, batch_size=64)
|
||||
new_vecs = np.array(new_vecs, dtype=np.float32)
|
||||
|
||||
# Essayer le serveur distant d'abord
|
||||
new_vecs = None
|
||||
try:
|
||||
from .remote_embed import embed_remote
|
||||
new_vecs = embed_remote(new_texts)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback local
|
||||
if new_vecs is None:
|
||||
model = _get_embed_model()
|
||||
new_vecs = model.encode(new_texts, normalize_embeddings=True, batch_size=64)
|
||||
new_vecs = np.array(new_vecs, dtype=np.float32)
|
||||
|
||||
with _embedding_cache_lock:
|
||||
for j, (i, t) in enumerate(to_compute):
|
||||
@@ -703,6 +726,36 @@ def enrich_acte(acte: ActeCCAM, contexte: dict, cache: OllamaCache | None = None
|
||||
logger.info("Ollama non disponible — sources FAISS CCAM conservées sans justification LLM")
|
||||
|
||||
|
||||
def _smart_truncate(text: str, max_chars: int = 6000) -> str:
|
||||
"""Troncature intelligente : garde le début + les sections finales importantes.
|
||||
|
||||
Pour les textes longs, on garde :
|
||||
- Les premiers 60% de max_chars (début du document : identité, motif, histoire)
|
||||
- Les derniers 40% (conclusion, synthèse, diagnostic de sortie, TTT)
|
||||
Séparés par un marqueur [...] pour indiquer la troncature.
|
||||
"""
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
|
||||
head_size = int(max_chars * 0.6)
|
||||
tail_size = max_chars - head_size - 30 # 30 chars pour le séparateur
|
||||
|
||||
# Chercher une fin de phrase propre pour le head
|
||||
head = text[:head_size]
|
||||
last_newline = head.rfind("\n")
|
||||
if last_newline > head_size * 0.8:
|
||||
head = head[:last_newline]
|
||||
|
||||
# Chercher un début de ligne propre pour le tail
|
||||
tail_start = len(text) - tail_size
|
||||
first_newline = text.find("\n", tail_start)
|
||||
if first_newline > 0 and first_newline < tail_start + 200:
|
||||
tail_start = first_newline + 1
|
||||
tail = text[tail_start:]
|
||||
|
||||
return head + "\n\n[... texte tronqué ...]\n\n" + tail
|
||||
|
||||
|
||||
def _build_prompt_das_extraction(text: str, contexte: dict, existing_das: list[str], dp_texte: str) -> str:
|
||||
"""Construit le prompt pour l'extraction LLM de DAS supplémentaires."""
|
||||
ctx_str = format_enriched_context(contexte)
|
||||
@@ -712,7 +765,7 @@ def _build_prompt_das_extraction(text: str, contexte: dict, existing_das: list[s
|
||||
dp_texte=dp_texte or "Non identifié",
|
||||
existing_str=existing_str,
|
||||
ctx_str=ctx_str,
|
||||
text_medical=text[:4000],
|
||||
text_medical=_smart_truncate(text, 6000),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -678,11 +678,25 @@ def _get_system_status() -> list[dict]:
|
||||
|
||||
|
||||
def _sort_qc_alerts(alerts: list[str]) -> list[str]:
|
||||
"""Trie les alertes QC : problèmes en haut, positif/recommandations en bas."""
|
||||
def _key(a: str) -> tuple[int, int]:
|
||||
text = a.lower()
|
||||
# Priorité 0 : erreurs critiques, incohérences, données aberrantes
|
||||
if any(k in text for k in ("erreur", "critique", "aberrant", "incompatible", "incohéren")):
|
||||
tier = 0
|
||||
# Priorité 1 : redondances, confusions, à reconsidérer
|
||||
elif any(k in text for k in ("redondance", "confusion", "reconsidérer", "reconsider", "high→low", "high → low", "flou")):
|
||||
tier = 1
|
||||
# Priorité 2 : recommandations, demandes de clarification
|
||||
elif any(k in text for k in ("recommandation", "demander", "clarification", "vérifier")):
|
||||
tier = 2
|
||||
# Priorité 3 : éléments positifs (codes justifiés, etc.)
|
||||
elif any(k in text for k in ("justifié", "solidement", "conforme", "validé")):
|
||||
tier = 3
|
||||
else:
|
||||
tier = 2
|
||||
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 (tier, dp)
|
||||
return sorted(alerts, key=_key)
|
||||
|
||||
|
||||
|
||||
@@ -13,9 +13,15 @@
|
||||
{% set vr = dossier.veto_report %}
|
||||
|
||||
<div class="card" style="margin-top:1rem;padding:1.25rem 1.5rem;">
|
||||
{# Titre patient #}
|
||||
{# Titre patient + identifiants #}
|
||||
<div style="display:flex;align-items:baseline;gap:0.75rem;margin-bottom:1rem;flex-wrap:wrap;">
|
||||
<h2 style="margin:0;">{{ current_group | format_dossier_name if current_group else (dossier.source_file or filepath) }}</h2>
|
||||
<span class="badge" style="background:#e0e7ff;color:#3730a3;font-size:0.75rem;font-weight:600;" title="Identifiant du dossier dans le système">N° {{ current_group | format_dossier_name if current_group else filepath }}</span>
|
||||
{% if dossier.controles_cpam %}
|
||||
{% for ctrl in dossier.controles_cpam %}
|
||||
<span class="badge" style="background:#fef3c7;color:#92400e;font-weight:700;font-size:0.8rem;" title="Numéro OGC (Ordonnance de Gestion de Caisse) du contrôle CPAM">OGC {{ ctrl.numero_ogc }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if dossier.document_type %}
|
||||
<span class="badge" style="background:#e0e7ff;color:#3730a3;" title="Type de document source (CRH, lettre de sortie, compte-rendu opératoire, etc.)">{{ dossier.document_type }}</span>
|
||||
{% endif %}
|
||||
@@ -290,8 +296,8 @@
|
||||
|
||||
{# ---- Diagnostics associés ---- #}
|
||||
{% if dossier.diagnostics_associes %}
|
||||
<div class="card section">
|
||||
<h3>Diagnostics associés ({{ dossier.diagnostics_associes|length }})</h3>
|
||||
<details class="card section">
|
||||
<summary><h3 style="display:inline;">Diagnostics associés ({{ dossier.diagnostics_associes|length }})</h3></summary>
|
||||
<table>
|
||||
<thead><tr><th title="Code CIM-10 attribué au diagnostic associé">Code CIM-10</th><th>Libellé</th><th title="Comorbidité/Morbidité Associée — indique si ce diagnostic augmente la sévérité GHM">CMA</th><th title="Niveau de confiance du pipeline IA sur ce code CIM-10">Confiance</th><th title="Source du diagnostic dans le document (page)">Source</th></tr></thead>
|
||||
<tbody>
|
||||
@@ -386,7 +392,7 @@
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{# ---- Actes CCAM ---- #}
|
||||
@@ -423,7 +429,7 @@
|
||||
{# ==================================================================== #}
|
||||
{# 3. CONTRÔLE QUALITÉ CODAGE (section repliable) #}
|
||||
{# ==================================================================== #}
|
||||
<details open class="card section" style="margin-top:1rem;">
|
||||
<details class="card section" style="margin-top:1rem;">
|
||||
<summary><h3 style="display:inline;">Contrôle Qualité Codage</h3></summary>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:1rem;margin-top:0.75rem;">
|
||||
|
||||
Reference in New Issue
Block a user