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:
dom
2026-03-08 12:36:54 +01:00
parent 214a5d1914
commit 0c38bc261b
12 changed files with 4365 additions and 82 deletions

File diff suppressed because one or more lines are too long

View File

@@ -318,5 +318,105 @@
"date_added": "2026-03-07T21:20:31.742855", "date_added": "2026-03-07T21:20:31.742855",
"status": "uploaded", "status": "uploaded",
"chunks_count": 0 "chunks_count": 0
},
{
"id": "e3f31431f656",
"filename": "doc.txt",
"stored_name": "user/e3f31431f656_doc.txt",
"extension": ".txt",
"size_bytes": 12,
"date_added": "2026-03-08T11:46:02.617694",
"status": "uploaded",
"chunks_count": 0
},
{
"id": "2ee6fb01e98f",
"filename": "doc.txt",
"stored_name": "user/2ee6fb01e98f_doc.txt",
"extension": ".txt",
"size_bytes": 12,
"date_added": "2026-03-08T11:52:46.222372",
"status": "uploaded",
"chunks_count": 0
},
{
"id": "69d6c1a41527",
"filename": "doc.txt",
"stored_name": "user/69d6c1a41527_doc.txt",
"extension": ".txt",
"size_bytes": 12,
"date_added": "2026-03-08T11:57:59.137425",
"status": "uploaded",
"chunks_count": 0
},
{
"id": "403b5641292f",
"filename": "doc.txt",
"stored_name": "user/403b5641292f_doc.txt",
"extension": ".txt",
"size_bytes": 12,
"date_added": "2026-03-08T12:06:01.501324",
"status": "uploaded",
"chunks_count": 0
},
{
"id": "7cc01afb71a0",
"filename": "doc.txt",
"stored_name": "user/7cc01afb71a0_doc.txt",
"extension": ".txt",
"size_bytes": 12,
"date_added": "2026-03-08T12:13:21.966620",
"status": "uploaded",
"chunks_count": 0
},
{
"id": "77a7fc0cde1c",
"filename": "doc.txt",
"stored_name": "user/77a7fc0cde1c_doc.txt",
"extension": ".txt",
"size_bytes": 12,
"date_added": "2026-03-08T12:15:30.956593",
"status": "uploaded",
"chunks_count": 0
},
{
"id": "177c5fb11e54",
"filename": "doc.txt",
"stored_name": "user/177c5fb11e54_doc.txt",
"extension": ".txt",
"size_bytes": 12,
"date_added": "2026-03-08T12:24:36.660660",
"status": "uploaded",
"chunks_count": 0
},
{
"id": "44842bce6001",
"filename": "doc.txt",
"stored_name": "user/44842bce6001_doc.txt",
"extension": ".txt",
"size_bytes": 12,
"date_added": "2026-03-08T12:26:51.303514",
"status": "uploaded",
"chunks_count": 0
},
{
"id": "e96e5bc74756",
"filename": "doc.txt",
"stored_name": "user/e96e5bc74756_doc.txt",
"extension": ".txt",
"size_bytes": 12,
"date_added": "2026-03-08T12:30:14.297678",
"status": "uploaded",
"chunks_count": 0
},
{
"id": "c1bee779405c",
"filename": "doc.txt",
"stored_name": "user/c1bee779405c_doc.txt",
"extension": ".txt",
"size_bytes": 12,
"date_added": "2026-03-08T12:36:28.174289",
"status": "uploaded",
"chunks_count": 0
} }
] ]

View File

@@ -211,6 +211,9 @@ def extract_medical_info(
# Post-processing : calcul DFG et détection IRC non codée # Post-processing : calcul DFG et détection IRC non codée
_check_dfg_irc(dossier) _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) # Post-processing : détection erreurs fréquentes (intuition DIM senior)
try: try:
from .dim_senior import check_common_mistakes from .dim_senior import check_common_mistakes
@@ -247,13 +250,15 @@ def extract_medical_info(
def _check_dfg_irc(dossier: DossierMedical) -> None: 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 from .bio_normals import compute_dfg, stade_irc
age = dossier.sejour.age if dossier.sejour else None age = dossier.sejour.age if dossier.sejour else None
sexe = dossier.sejour.sexe 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 # Trouver la créatinine
creat_val = None creat_val = None
@@ -264,6 +269,33 @@ def _check_dfg_irc(dossier: DossierMedical) -> None:
if creat_val is None: if creat_val is None:
return 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) dfg = compute_dfg(creat_val, age, sexe)
if dfg is None: if dfg is None:
return return
@@ -273,22 +305,79 @@ def _check_dfg_irc(dossier: DossierMedical) -> None:
# Vérifier si IRC codée alors que DFG le justifie # Vérifier si IRC codée alors que DFG le justifie
if dfg < 60: if dfg < 60:
stade, code_attendu = stade_irc(dfg) stade, code_attendu = stade_irc(dfg)
# Chercher si un N18.x est déjà codé irc_coded = any(c.startswith("N18") for c in all_codes)
irc_coded = False
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"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() all_codes = set()
if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion: if dossier.diagnostic_principal and dossier.diagnostic_principal.cim10_suggestion:
all_codes.add(dossier.diagnostic_principal.cim10_suggestion) all_codes.add(dossier.diagnostic_principal.cim10_suggestion)
for das in dossier.diagnostics_associes: for das in dossier.diagnostics_associes:
if das.cim10_suggestion: if das.cim10_suggestion:
all_codes.add(das.cim10_suggestion) all_codes.add(das.cim10_suggestion)
irc_coded = any(c.startswith("N18") for c in all_codes)
if not irc_coded: 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( dossier.alertes_codage.append(
f"DFG estimé {dfg} mL/min (stade {stade}) — IRC ({code_attendu}) " f"AUTO-CODE BIO: {code} ({texte}) ajouté — {bio.test} = {bio.valeur}"
f"non codée. Créatinine {creat_val} µmol/L, {sexe} {age} ans."
) )
dossier.quality_flags["irc_non_codee"] = code_attendu
def _extract_das_llm(text: str, dossier: DossierMedical) -> None: def _extract_das_llm(text: str, dossier: DossierMedical) -> None:

View File

@@ -320,6 +320,38 @@ def finalize_dp(dossier: DossierMedical) -> DossierMedical:
# ── 3. Arbitrage ─────────────────────────────────────────────── # ── 3. Arbitrage ───────────────────────────────────────────────
dp_final, flags, alertes = decide_dp_final(trackare_dp, crh_dp) 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 ──────────────────────────────────── # ── 4. Écrire les résultats ────────────────────────────────────
dossier.dp_final = dp_final dossier.dp_final = dp_final

View File

@@ -256,15 +256,15 @@ def score_candidates(
score += _diag_section_bonus score += _diag_section_bonus
details["diag_section_bonus"] = _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: if c.is_comorbidity_like:
score -= 3 score -= 1.5
details["comorbidity_malus"] = -3 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: if c.is_symptom_like:
score -= 2 score -= 3
details["symptom_malus"] = -2 details["symptom_malus"] = -3
# 7. Malus acte-seul # 7. Malus acte-seul
if c.is_act_only: if c.is_act_only:
@@ -579,6 +579,23 @@ def select_dp(
reason="Aucun candidat DP identifié", 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) # Candidat unique → CONFIRMED (sous réserve hardening A2)
if len(candidates) == 1: if len(candidates) == 1:
c = candidates[0] c = candidates[0]

View File

@@ -83,10 +83,38 @@ def get_pipeline():
def analyze(text: str) -> EdsnlpResult: def analyze(text: str) -> EdsnlpResult:
"""Analyse un texte médical avec edsnlp. """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. Retourne les entités CIM-10, médicaments et dates détectées.
""" """
result = EdsnlpResult() 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(): if not is_available():
return result return result

View File

@@ -35,22 +35,47 @@ def _cim10_specificity(code: str | None) -> int:
return len(code.replace(".", "")) 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: def _prefer_most_specific_dp(dossiers: list[DossierMedical]) -> Diagnostic | None:
"""Sélectionne le DP le plus spécifique parmi tous les dossiers.""" """Sélectionne le meilleur DP parmi tous les dossiers.
candidates: list[tuple[Diagnostic, int]] = []
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: for d in dossiers:
if d.diagnostic_principal: if d.diagnostic_principal and d.diagnostic_principal.cim10_suggestion:
spec = _cim10_specificity(d.diagnostic_principal.cim10_suggestion) candidates.append((d.diagnostic_principal, d.document_type or ""))
candidates.append((d.diagnostic_principal, spec))
if not candidates: if not candidates:
return None return None
# Tri : spécificité décroissante, puis confiance (high > medium > low) candidates.sort(key=lambda x: _dp_sort_score(x[0], x[1]))
conf_order = {"high": 0, "medium": 1, "low": 2}
candidates.sort(
key=lambda x: (-x[1], conf_order.get(x[0].cim10_confidence or "", 3))
)
return candidates[0][0] return candidates[0][0]
@@ -189,15 +214,56 @@ def merge_dossiers(dossiers: list[DossierMedical]) -> DossierMedical:
merged.diagnostic_principal = _prefer_most_specific_dp(dossiers) merged.diagnostic_principal = _prefer_most_specific_dp(dossiers)
# Propager dp_selection depuis le dossier source du DP retenu # Propager dp_selection depuis le dossier source du DP retenu
from ..config import DPSelection
if merged.diagnostic_principal: if merged.diagnostic_principal:
dp_code = merged.diagnostic_principal.cim10_suggestion dp_code = merged.diagnostic_principal.cim10_suggestion
for d in dossiers: for d in dossiers:
if (d.diagnostic_principal if (d.diagnostic_principal
and d.diagnostic_principal.cim10_suggestion == dp_code and d.diagnostic_principal.cim10_suggestion == dp_code):
and d.dp_selection is not None): if d.dp_selection is not None:
merged.dp_selection = d.dp_selection 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 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 # Collecter tous les DAS + DP non retenus comme DAS
all_das: list[Diagnostic] = [] all_das: list[Diagnostic] = []
for d in dossiers: for d in dossiers:

View File

@@ -149,7 +149,7 @@ def parse_json_response(raw: str) -> dict | None:
def call_ollama( def call_ollama(
prompt: str, prompt: str,
temperature: float = 0.1, temperature: float = 0.1,
max_tokens: int = 2500, max_tokens: int = 4000,
model: str | None = None, model: str | None = None,
timeout: int | None = None, timeout: int | None = None,
role: str | None = None, role: str | None = None,

View File

@@ -122,10 +122,22 @@ def _rerank(query: str, results: list[dict], top_k: int) -> list[dict]:
if not results: if not results:
return results return results
reranker = _get_reranker() passages = [r.get("extrait", "") for r in results]
# Construire les paires (query, passage) pour le cross-encoder # Essayer le serveur distant d'abord
pairs = [(query, r.get("extrait", "")) for r in results] 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) ce_scores = reranker.predict(pairs)
# Injecter le score cross-encoder et trier # Injecter le score cross-encoder et trier
@@ -138,10 +150,9 @@ def _rerank(query: str, results: list[dict], top_k: int) -> list[dict]:
def _embed_cached(texts: list[str]) -> "numpy.ndarray": 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 import numpy as np
model = _get_embed_model()
results = [None] * len(texts) results = [None] * len(texts)
to_compute: list[tuple[int, str]] = [] to_compute: list[tuple[int, str]] = []
@@ -155,6 +166,18 @@ def _embed_cached(texts: list[str]) -> "numpy.ndarray":
if to_compute: if to_compute:
new_texts = [t for _, t in to_compute] new_texts = [t for _, t in to_compute]
# 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 = model.encode(new_texts, normalize_embeddings=True, batch_size=64)
new_vecs = np.array(new_vecs, dtype=np.float32) new_vecs = np.array(new_vecs, dtype=np.float32)
@@ -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") 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: 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.""" """Construit le prompt pour l'extraction LLM de DAS supplémentaires."""
ctx_str = format_enriched_context(contexte) 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é", dp_texte=dp_texte or "Non identifié",
existing_str=existing_str, existing_str=existing_str,
ctx_str=ctx_str, ctx_str=ctx_str,
text_medical=text[:4000], text_medical=_smart_truncate(text, 6000),
) )

View File

@@ -678,11 +678,25 @@ def _get_system_status() -> list[dict]:
def _sort_qc_alerts(alerts: list[str]) -> list[str]: 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]: def _key(a: str) -> tuple[int, int]:
text = a.lower() 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 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 (tier, dp)
return (dp, critical)
return sorted(alerts, key=_key) return sorted(alerts, key=_key)

View File

@@ -13,9 +13,15 @@
{% set vr = dossier.veto_report %} {% set vr = dossier.veto_report %}
<div class="card" style="margin-top:1rem;padding:1.25rem 1.5rem;"> <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;"> <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> <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 %} {% 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> <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 %} {% endif %}
@@ -290,8 +296,8 @@
{# ---- Diagnostics associés ---- #} {# ---- Diagnostics associés ---- #}
{% if dossier.diagnostics_associes %} {% if dossier.diagnostics_associes %}
<div class="card section"> <details class="card section">
<h3>Diagnostics associés ({{ dossier.diagnostics_associes|length }})</h3> <summary><h3 style="display:inline;">Diagnostics associés ({{ dossier.diagnostics_associes|length }})</h3></summary>
<table> <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> <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> <tbody>
@@ -386,7 +392,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </details>
{% endif %} {% endif %}
{# ---- Actes CCAM ---- #} {# ---- Actes CCAM ---- #}
@@ -423,7 +429,7 @@
{# ==================================================================== #} {# ==================================================================== #}
{# 3. CONTRÔLE QUALITÉ CODAGE (section repliable) #} {# 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> <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;"> <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:1rem;margin-top:0.75rem;">

View File

@@ -9,8 +9,8 @@ User=dom
WorkingDirectory=/home/dom/ai/t2a_v2 WorkingDirectory=/home/dom/ai/t2a_v2
EnvironmentFile=/home/dom/ai/t2a_v2/.env EnvironmentFile=/home/dom/ai/t2a_v2/.env
ExecStart=/home/dom/ai/t2a/.venv/bin/gunicorn -c gunicorn.conf.py "src.viewer.app:create_app()" ExecStart=/home/dom/ai/t2a/.venv/bin/gunicorn -c gunicorn.conf.py "src.viewer.app:create_app()"
Restart=on-failure Restart=always
RestartSec=5 RestartSec=10
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal