feat: traçabilité source systématique + viewer interactif

Ajoute source_page/source_excerpt à tous les types (biologie, imagerie,
traitements, actes CCAM, antécédents, complications). Convertit antecedents
et complications en types structurés (Antecedent/Complication) avec
validators backward-compat pour les vieux JSON. Étend _apply_source_tracking
à tous les éléments du dossier. Ajoute un endpoint /api/source-text/ et un
modal interactif dans le viewer avec surlignage du texte source.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dom
2026-02-18 20:59:50 +01:00
parent fe22c0f0f5
commit 40934fdc39
10 changed files with 500 additions and 47 deletions

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from typing import Optional
from dotenv import load_dotenv
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
load_dotenv()
@@ -125,24 +125,44 @@ class ActeCCAM(BaseModel):
date: Optional[str] = None
validite: Optional[str] = None # "valide" | "obsolete" | "non_verifie"
alertes: list[str] = Field(default_factory=list)
source_page: Optional[int] = None
source_excerpt: Optional[str] = None
class Traitement(BaseModel):
medicament: str
posologie: Optional[str] = None
code_atc: Optional[str] = None
source_page: Optional[int] = None
source_excerpt: Optional[str] = None
class BiologieCle(BaseModel):
test: str
valeur: Optional[str] = None
anomalie: Optional[bool] = None
source_page: Optional[int] = None
source_excerpt: Optional[str] = None
class Imagerie(BaseModel):
type: str
conclusion: Optional[str] = None
score: Optional[str] = None
source_page: Optional[int] = None
source_excerpt: Optional[str] = None
class Antecedent(BaseModel):
texte: str
source_page: Optional[int] = None
source_excerpt: Optional[str] = None
class Complication(BaseModel):
texte: str
source_page: Optional[int] = None
source_excerpt: Optional[str] = None
class DossierMedical(BaseModel):
@@ -152,17 +172,45 @@ class DossierMedical(BaseModel):
diagnostic_principal: Optional[Diagnostic] = None
diagnostics_associes: list[Diagnostic] = Field(default_factory=list)
actes_ccam: list[ActeCCAM] = Field(default_factory=list)
antecedents: list[str] = Field(default_factory=list)
antecedents: list[Antecedent] = Field(default_factory=list)
traitements_sortie: list[Traitement] = Field(default_factory=list)
biologie_cle: list[BiologieCle] = Field(default_factory=list)
imagerie: list[Imagerie] = Field(default_factory=list)
complications: list[str] = Field(default_factory=list)
complications: list[Complication] = Field(default_factory=list)
alertes_codage: list[str] = Field(default_factory=list)
source_files: list[str] = Field(default_factory=list)
ghm_estimation: Optional[GHMEstimation] = None
controles_cpam: list[ControleCPAM] = Field(default_factory=list)
processing_time_s: float | None = None
@field_validator("antecedents", mode="before")
@classmethod
def _coerce_antecedents(cls, v):
"""Backward compat : convertit les anciennes list[str] en list[Antecedent]."""
if not isinstance(v, list):
return v
result = []
for item in v:
if isinstance(item, str):
result.append({"texte": item})
else:
result.append(item)
return result
@field_validator("complications", mode="before")
@classmethod
def _coerce_complications(cls, v):
"""Backward compat : convertit les anciennes list[str] en list[Complication]."""
if not isinstance(v, list):
return v
result = []
for item in v:
if isinstance(item, str):
result.append({"texte": item})
else:
result.append(item)
return result
# --- Rapport d'anonymisation ---

View File

@@ -470,10 +470,10 @@ def _build_cpam_prompt(
dossier_lines.append(f"- Traitements de sortie : {', '.join(trt_parts)}")
if dossier.antecedents:
dossier_lines.append(f"- Antécédents : {', '.join(dossier.antecedents[:10])}")
dossier_lines.append(f"- Antécédents : {', '.join(a.texte for a in dossier.antecedents[:10])}")
if dossier.complications:
dossier_lines.append(f"- Complications : {', '.join(dossier.complications)}")
dossier_lines.append(f"- Complications : {', '.join(c.texte for c in dossier.complications)}")
dossier_str = "\n".join(dossier_lines) if dossier_lines else "Non disponible"

View File

@@ -14,7 +14,9 @@ from .ccam_dict import lookup as ccam_lookup, validate_code as ccam_validate
from .das_filter import clean_diagnostic_text, is_valid_diagnostic_text, correct_known_miscodes
from ..config import (
ActeCCAM,
Antecedent,
BiologieCle,
Complication,
Diagnostic,
DossierMedical,
Imagerie,
@@ -180,10 +182,10 @@ def _extract_das_llm(text: str, dossier: DossierMedical) -> None:
"age": dossier.sejour.age,
"duree_sejour": dossier.sejour.duree_sejour,
"imc": dossier.sejour.imc,
"antecedents": dossier.antecedents[:5],
"antecedents": [a.texte for a in dossier.antecedents[:5]],
"biologie_cle": [(b.test, b.valeur, b.anomalie) for b in dossier.biologie_cle],
"imagerie": [(i.type, (i.conclusion or "")[:200]) for i in dossier.imagerie],
"complications": dossier.complications,
"complications": [c.texte for c in dossier.complications],
}
# DAS existants (texte + code)
@@ -532,7 +534,8 @@ _ANTECEDENT_NOISE = (
"item de", "surveillance", "température", "signes vitaux",
"pouls", "type de note", "aucune donnée", "renseignée",
"habitudes de vie", "systolique", "diastolique", "saturation",
"texte libre", "mode de vie", "n° rpps",
"texte libre", "mode de vie", "n° rpps", "secrétariat",
"aucune aide",
)
_SURVEILLANCE_SINGLE_WORDS = frozenset({
@@ -569,8 +572,14 @@ def _is_valid_antecedent(line: str) -> bool:
# Deux mots identiques
if len(words) == 2 and len(set(words)) == 1:
return False
# Identifiants administratifs isolés
if re.match(r'^\[MEDECIN\]\s', line) and len(line) < 30:
# Lignes commençant par un tag médecin (artefact colonne gauche CRH)
if re.match(r'^\[MEDECIN', line):
return False
# Lignes commençant par "Dr [MEDECIN" ou "Dr[PERSONNE" (nom de médecin)
if re.match(r'^Dr\s*\[', line):
return False
# Fragment de localisation : "de Bordeaux", "de Lyon", "de Paris"
if re.match(r'^de [A-ZÀ-Ú]', line) and len(line) < 25:
return False
return True
@@ -578,7 +587,7 @@ def _is_valid_antecedent(line: str) -> bool:
def _extract_antecedents(text: str, dossier: DossierMedical) -> None:
"""Extrait les antécédents."""
m = re.search(
r"Antécédents?\s*[:]?\s*\n?(.*?)(?=\n\s*(?:Traitements?\s*[:]|Allergie|Histoire de la maladie|Examen clinique|Signes\s+[Vv]itaux|Observations?\s+m[eé]dicale|Passage aux|\n\n))",
r"Antécédents?\s*[:]?\s*\n?(.*?)(?=\n\s*(?:Traitements?\s*[:]|Allergie|Histoire de la maladie|Examen clinique|Signes\s+[Vv]itaux|Observations?\s+m[eé]dicale|Passage aux|Mode de vie|\n\n))",
text,
re.DOTALL | re.IGNORECASE,
)
@@ -587,7 +596,7 @@ def _extract_antecedents(text: str, dossier: DossierMedical) -> None:
for line in block.split("\n"):
line = line.strip().lstrip("- •")
if _is_valid_antecedent(line):
dossier.antecedents.append(line)
dossier.antecedents.append(Antecedent(texte=line))
def _extract_traitements(
@@ -778,7 +787,7 @@ def _extract_complications(
# Fallback regex pour la négation
pattern = rf"(?:pas de|sans|absence de|aucun[e]?)\s+{re.escape(term)}"
if not re.search(pattern, text_lower):
dossier.complications.append(term.capitalize())
dossier.complications.append(Complication(texte=term.capitalize()))
def _is_negated_by_edsnlp(term: str, negated_terms: set[str]) -> bool:
@@ -1028,34 +1037,84 @@ def _is_abnormal(test: str, value: str) -> bool | None:
return None
def _apply_source_tracking(dossier: DossierMedical, page_tracker, search_text: str) -> None:
"""Ajoute la traçabilité source (page + extrait) à chaque diagnostic.
def _track_item(item, search_key: str, page_tracker, search_text: str) -> bool:
"""Cherche la page source et l'extrait pour un item avec source_page/source_excerpt."""
if item.source_page is not None:
return False
if not search_key:
return False
page = page_tracker.find_page_for_text(search_key, search_text)
if page:
item.source_page = page
item.source_excerpt = page_tracker.extract_excerpt(search_key, search_text)
return True
return False
Cherche le texte du diagnostic dans le texte source pour retrouver
def _apply_source_tracking(dossier: DossierMedical, page_tracker, search_text: str) -> None:
"""Ajoute la traçabilité source (page + extrait) à tous les éléments du dossier.
Cherche le texte de chaque élément dans le texte source pour retrouver
la page d'origine et extraire un passage contextualisé.
"""
tracked = 0
total = 0
# Diagnostics (DP + DAS)
all_diags: list[Diagnostic] = []
if dossier.diagnostic_principal:
all_diags.append(dossier.diagnostic_principal)
all_diags.extend(dossier.diagnostics_associes)
tracked = 0
for diag in all_diags:
if diag.source_page is not None:
continue # déjà renseigné
total += 1
if _track_item(diag, diag.texte, page_tracker, search_text):
tracked += 1
texte = diag.texte
if not texte:
continue
# Biologie
for b in dossier.biologie_cle:
total += 1
search_key = f"{b.test}: {b.valeur}" if b.valeur else b.test
if _track_item(b, search_key, page_tracker, search_text):
tracked += 1
elif b.valeur and _track_item(b, b.test, page_tracker, search_text):
tracked += 1
page = page_tracker.find_page_for_text(texte, search_text)
if page:
diag.source_page = page
diag.source_excerpt = page_tracker.extract_excerpt(texte, search_text)
# Imagerie
for img in dossier.imagerie:
total += 1
search_key = img.type
if _track_item(img, search_key, page_tracker, search_text):
tracked += 1
elif img.conclusion and _track_item(img, img.conclusion[:50], page_tracker, search_text):
tracked += 1
# Traitements
for t in dossier.traitements_sortie:
total += 1
if _track_item(t, t.medicament, page_tracker, search_text):
tracked += 1
# Actes CCAM
for a in dossier.actes_ccam:
total += 1
if _track_item(a, a.texte, page_tracker, search_text):
tracked += 1
# Antécédents
for ant in dossier.antecedents:
total += 1
if _track_item(ant, ant.texte, page_tracker, search_text):
tracked += 1
# Complications
for comp in dossier.complications:
total += 1
if _track_item(comp, comp.texte, page_tracker, search_text):
tracked += 1
if tracked:
logger.info(" Traçabilité source : %d/%d diagnostics localisés", tracked, len(all_diags))
logger.info(" Traçabilité source : %d/%d éléments localisés", tracked, total)
def _validate_justifications(dossier: DossierMedical) -> None:

View File

@@ -166,10 +166,10 @@ def build_enriched_context(dossier: DossierMedical) -> dict:
"age": dossier.sejour.age,
"duree_sejour": dossier.sejour.duree_sejour,
"imc": dossier.sejour.imc,
"antecedents": dossier.antecedents[:5],
"antecedents": [a.texte for a in dossier.antecedents[:5]],
"biologie_cle": [(b.test, b.valeur, b.anomalie) for b in dossier.biologie_cle],
"imagerie": [(i.type, (i.conclusion or "")[:200]) for i in dossier.imagerie],
"complications": dossier.complications,
"complications": [c.texte for c in dossier.complications],
}
# Interprétations biologiques

View File

@@ -10,7 +10,9 @@ import logging
from ..config import (
ActeCCAM,
Antecedent,
BiologieCle,
Complication,
Diagnostic,
DossierMedical,
Imagerie,
@@ -251,7 +253,7 @@ def merge_dossiers(dossiers: list[DossierMedical]) -> DossierMedical:
ant_seen: set[str] = set()
for d in dossiers:
for a in d.antecedents:
key = a.lower().strip()
key = a.texte.lower().strip()
if key not in ant_seen:
merged.antecedents.append(a)
ant_seen.add(key)
@@ -260,7 +262,7 @@ def merge_dossiers(dossiers: list[DossierMedical]) -> DossierMedical:
comp_seen: set[str] = set()
for d in dossiers:
for c in d.complications:
key = c.lower().strip()
key = c.texte.lower().strip()
if key not in comp_seen:
merged.complications.append(c)
comp_seen.add(key)

View File

@@ -16,7 +16,7 @@ from werkzeug.utils import secure_filename
from collections import Counter
from ..config import (
STRUCTURED_DIR, OLLAMA_URL, CCAM_DICT_PATH, DossierMedical,
ANONYMIZED_DIR, STRUCTURED_DIR, OLLAMA_URL, CCAM_DICT_PATH, DossierMedical,
ALLOWED_EXTENSIONS, UPLOAD_MAX_SIZE_MB,
CIM10_PDF, GUIDE_METHODO_PDF, CCAM_PDF, CIM10_DICT_PATH, CIM10_SUPPLEMENTS_PATH,
)
@@ -594,6 +594,27 @@ def create_app() -> Flask:
logger.exception("Erreur lors du retraitement")
return jsonify({"error": str(e)}), 500
# ------------------------------------------------------------------
# API texte source anonymisé
# ------------------------------------------------------------------
@app.route("/api/source-text/<path:dossier_id>")
def source_text(dossier_id: str):
"""Retourne le contenu texte anonymisé de tous les fichiers d'un dossier."""
safe_dir = (ANONYMIZED_DIR / dossier_id).resolve()
if not safe_dir.is_relative_to(ANONYMIZED_DIR.resolve()):
abort(403)
if not safe_dir.is_dir():
abort(404)
result = {}
for txt_path in sorted(safe_dir.glob("*_anonymized.txt")):
try:
result[txt_path.name] = txt_path.read_text(encoding="utf-8")
except Exception:
logger.warning("Impossible de lire %s", txt_path)
return jsonify(result)
# ------------------------------------------------------------------
# Routes admin référentiels
# ------------------------------------------------------------------

View File

@@ -233,6 +233,79 @@
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Source tracking badges */
.src-btn {
display: inline-block;
padding: 1px 6px;
border-radius: 9999px;
font-size: 0.65rem;
font-weight: 600;
background: #e0f2fe;
color: #0369a1;
border: 1px solid #bae6fd;
cursor: pointer;
margin-left: 0.3rem;
vertical-align: middle;
transition: background 0.15s;
}
.src-btn:hover { background: #bae6fd; }
/* Source modal */
#source-modal {
display: none;
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0,0,0,0.5);
padding: 2rem;
}
#source-modal-inner {
background: #fff;
border-radius: 12px;
max-width: 900px;
margin: 0 auto;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 30px rgba(0,0,0,0.2);
}
#source-header {
padding: 1rem 1.25rem;
border-bottom: 1px solid #e2e8f0;
font-weight: 700;
font-size: 0.9rem;
color: #0f172a;
display: flex;
justify-content: space-between;
align-items: center;
}
#source-content {
flex: 1;
overflow-y: auto;
padding: 1.25rem;
font-size: 0.85rem;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
color: #334155;
}
#source-content mark {
background: #fef08a;
padding: 2px 0;
border-radius: 2px;
}
#source-close-btn {
padding: 0.4rem 1rem;
background: #64748b;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
}
#source-close-btn:hover { background: #475569; }
</style>
</head>
<body>

View File

@@ -287,7 +287,10 @@
{% set dp = dossier.diagnostic_principal %}
<div class="card section">
<h3>Diagnostic principal</h3>
<div style="font-size:0.95rem;margin-bottom:0.5rem;">{{ dp.texte }}</div>
<div style="font-size:0.95rem;margin-bottom:0.5rem;">
{{ dp.texte }}
{% if dp.source_page %}<button class="src-btn" onclick="showSource('{{ dp.source_excerpt|default('',true)|e }}', {{ dp.source_page }})">p.{{ dp.source_page }}</button>{% endif %}
</div>
{% if dp.cim10_suggestion %}
<span class="badge" style="background:#dbeafe;color:#1d4ed8;font-size:0.85rem;">{{ dp.cim10_suggestion }}</span>
{{ dp.cim10_confidence | confidence_badge }}
@@ -355,12 +358,7 @@
<span class="badge" style="background:#e0e7ff;color:#3730a3;font-size:0.7rem;">{{ das.source }}</span>
{% endif %}
{% if das.source_page %}
<span style="font-size:0.7rem;color:#64748b;">p.{{ das.source_page }}</span>
{% endif %}
{% if das.source_excerpt %}
<details style="margin-top:0.2rem;"><summary style="font-size:0.7rem;color:#94a3b8;cursor:pointer;">extrait</summary>
<pre style="font-size:0.7rem;white-space:pre-wrap;max-width:300px;color:#475569;">{{ das.source_excerpt }}</pre>
</details>
<button class="src-btn" onclick="showSource('{{ das.source_excerpt|default('',true)|e }}', {{ das.source_page }})">p.{{ das.source_page }}</button>
{% endif %}
</td>
<td style="font-size:0.8rem;color:#475569;">
@@ -410,7 +408,7 @@
<div class="card section">
<h3>Actes CCAM ({{ dossier.actes_ccam|length }})</h3>
<table>
<thead><tr><th>Texte</th><th>Code CCAM</th><th>Regroupement</th><th>Date</th><th>Validité</th></tr></thead>
<thead><tr><th>Texte</th><th>Code CCAM</th><th>Regroupement</th><th>Date</th><th>Validité</th><th>Source</th></tr></thead>
<tbody>
{% for a in dossier.actes_ccam %}
<tr>
@@ -432,6 +430,7 @@
<div style="font-size:0.7rem;color:#dc2626;">{{ alerte }}</div>
{% endfor %}
</td>
<td>{% if a.source_page %}<button class="src-btn" onclick="showSource('{{ a.source_excerpt|default('',true)|e }}', {{ a.source_page }})">p.{{ a.source_page }}</button>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
@@ -444,13 +443,14 @@
<div class="card section">
<h3>Biologie clé ({{ dossier.biologie_cle|length }})</h3>
<table>
<thead><tr><th>Test</th><th>Valeur</th><th>Anomalie</th></tr></thead>
<thead><tr><th>Test</th><th>Valeur</th><th>Anomalie</th><th>Source</th></tr></thead>
<tbody>
{% for b in dossier.biologie_cle %}
<tr{% if b.anomalie %} class="anomalie"{% endif %}>
<td>{{ b.test }}</td>
<td>{{ b.valeur or '' }}</td>
<td>{% if b.anomalie %}<span class="badge" style="background:#fee2e2;color:#dc2626;">Oui</span>{% else %}—{% endif %}</td>
<td>{% if b.source_page %}<button class="src-btn" onclick="showSource('{{ b.source_excerpt|default('',true)|e }}', {{ b.source_page }})">p.{{ b.source_page }}</button>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
@@ -466,6 +466,7 @@
<div style="margin-bottom:0.5rem;">
<strong>{{ img.type }}</strong>
{% if img.score %} — Score : {{ img.score }}{% endif %}
{% if img.source_page %}<button class="src-btn" onclick="showSource('{{ img.source_excerpt|default('',true)|e }}', {{ img.source_page }})">p.{{ img.source_page }}</button>{% endif %}
{% if img.conclusion %}
<div style="font-size:0.85rem;color:#475569;">{{ img.conclusion }}</div>
{% endif %}
@@ -479,13 +480,14 @@
<div class="card section">
<h3>Traitements de sortie ({{ dossier.traitements_sortie|length }})</h3>
<table>
<thead><tr><th>Médicament</th><th>Posologie</th><th>Code ATC</th></tr></thead>
<thead><tr><th>Médicament</th><th>Posologie</th><th>Code ATC</th><th>Source</th></tr></thead>
<tbody>
{% for t in dossier.traitements_sortie %}
<tr>
<td>{{ t.medicament }}</td>
<td>{{ t.posologie or '' }}</td>
<td>{% if t.code_atc %}<span class="badge" style="background:#e0e7ff;color:#3730a3;">{{ t.code_atc }}</span>{% endif %}</td>
<td>{% if t.source_page %}<button class="src-btn" onclick="showSource('{{ t.source_excerpt|default('',true)|e }}', {{ t.source_page }})">p.{{ t.source_page }}</button>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
@@ -499,7 +501,7 @@
<h3>Antécédents ({{ dossier.antecedents|length }})</h3>
<ul class="bullet">
{% for a in dossier.antecedents %}
<li>{{ a }}</li>
<li>{{ a.texte }}{% if a.source_page %} <button class="src-btn" onclick="showSource('{{ a.source_excerpt|default('',true)|e }}', {{ a.source_page }})">p.{{ a.source_page }}</button>{% endif %}</li>
{% endfor %}
</ul>
</div>
@@ -511,16 +513,109 @@
<h3>Complications ({{ dossier.complications|length }})</h3>
<ul class="bullet">
{% for c in dossier.complications %}
<li>{{ c }}</li>
<li>{{ c.texte }}{% if c.source_page %} <button class="src-btn" onclick="showSource('{{ c.source_excerpt|default('',true)|e }}', {{ c.source_page }})">p.{{ c.source_page }}</button>{% endif %}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{# ---- Modal source ---- #}
<div id="source-modal">
<div id="source-modal-inner">
<div id="source-header">
<span id="source-title">Document source</span>
<button id="source-close-btn" onclick="closeSource()">Fermer</button>
</div>
<div id="source-content"></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
/* --- Source modal --- */
let _sourceCache = null;
function getDossierId() {
// filepath = "103_23056749/103_23056749_fusionne_cim10.json"
// dossier_id = "103_23056749"
const fp = '{{ filepath }}';
const parts = fp.split('/');
return parts.length > 1 ? parts.slice(0, -1).join('/') : '';
}
async function loadSourceTexts() {
if (_sourceCache !== null) return _sourceCache;
const dossierId = getDossierId();
if (!dossierId) { _sourceCache = {}; return _sourceCache; }
try {
const resp = await fetch('/api/source-text/' + dossierId);
if (resp.ok) { _sourceCache = await resp.json(); }
else { _sourceCache = {}; }
} catch (e) { _sourceCache = {}; }
return _sourceCache;
}
async function showSource(excerpt, page) {
const modal = document.getElementById('source-modal');
const content = document.getElementById('source-content');
const title = document.getElementById('source-title');
title.textContent = 'Document source — Page ' + page;
content.innerHTML = '<em style="color:#94a3b8;">Chargement...</em>';
modal.style.display = 'block';
const texts = await loadSourceTexts();
const allText = Object.values(texts).join('\n\n--- ---\n\n');
if (!allText) {
content.innerHTML = '<em style="color:#94a3b8;">Texte source non disponible</em>';
return;
}
// Chercher l'extrait dans le texte et le surligner
if (excerpt && excerpt.length > 10) {
const idx = allText.indexOf(excerpt);
if (idx >= 0) {
const before = allText.substring(0, idx);
const match = allText.substring(idx, idx + excerpt.length);
const after = allText.substring(idx + excerpt.length);
content.innerHTML = '';
content.appendChild(document.createTextNode(before));
const mark = document.createElement('mark');
mark.textContent = match;
mark.id = 'source-highlight';
content.appendChild(mark);
content.appendChild(document.createTextNode(after));
// Scroll vers le surlignage
setTimeout(() => {
const el = document.getElementById('source-highlight');
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
return;
}
}
// Fallback : afficher le texte brut sans surlignage
content.textContent = allText;
}
function closeSource() {
document.getElementById('source-modal').style.display = 'none';
}
// Fermer le modal en cliquant sur le fond
document.getElementById('source-modal').addEventListener('click', function(e) {
if (e.target === this) closeSource();
});
// Fermer avec Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeSource();
});
/* --- Reprocess --- */
document.getElementById('reprocess-btn').addEventListener('click', async () => {
const btn = document.getElementById('reprocess-btn');
const status = document.getElementById('reprocess-status');

View File

@@ -2,7 +2,7 @@
import pytest
from src.config import DossierMedical, Diagnostic
from src.config import DossierMedical, Diagnostic, Antecedent, Complication
from src.medical.cim10_extractor import (
extract_medical_info,
_lookup_cim10,
@@ -121,7 +121,7 @@ Devenir : sortie le 03/03."""
assert any("Balthazar" in (i.score or "") for i in dossier.imagerie)
# Complications
assert any("cutanée" in c.lower() for c in dossier.complications)
assert any("cutanée" in c.texte.lower() for c in dossier.complications)
def test_extract_without_edsnlp(self):
"""Vérifie que l'extraction fonctionne sans résultat edsnlp."""
@@ -236,7 +236,7 @@ Devenir : sortie le 03/03."""
dossier = extract_medical_info(parsed, text, edsnlp_result=edsnlp_result)
# Fièvre et infection sont niées, ne doivent pas apparaître dans complications
complication_terms = [c.lower() for c in dossier.complications]
complication_terms = [c.texte.lower() for c in dossier.complications]
assert "fièvre" not in complication_terms
assert "infection" not in complication_terms
@@ -504,6 +504,44 @@ class TestIsValidAntecedent:
def test_reject_texte_libre(self):
assert not _is_valid_antecedent("(texte libre)")
# --- Artefacts CRH colonne gauche (médecins) ---
def test_reject_medecin_tag_start(self):
assert not _is_valid_antecedent(
"[MEDECIN] hospitalier - Syndrome anxio depressif suivi Dr [MEDECIN_39]"
)
def test_reject_medecin_assistant(self):
assert not _is_valid_antecedent(
"[MEDECIN] Assistant des Hôpitaux de Lyon - Bilan neurologique"
)
def test_reject_medecin_contractuel(self):
assert not _is_valid_antecedent("[MEDECIN] hospitalier contractuel")
def test_reject_dr_medecin_tag(self):
assert not _is_valid_antecedent("Dr [MEDECIN_7] (Caradoc)")
def test_reject_dr_chef_clinique(self):
assert not _is_valid_antecedent(
"Dr [MEDECIN_37] Chef de Clinique des Hôpitaux aucune aide"
)
def test_reject_de_bordeaux(self):
assert not _is_valid_antecedent("de Bordeaux")
def test_reject_de_lyon(self):
assert not _is_valid_antecedent("de Lyon")
def test_reject_secretariat(self):
assert not _is_valid_antecedent("Secrétariat : [TEL_3] - fracture en 2017")
def test_reject_aucune_aide(self):
assert not _is_valid_antecedent("aucune aide, pas d'ide, pas d'aide ménagère")
def test_accept_de_long_medical(self):
"""'de' suivi d'une vraie description médicale longue passe."""
assert _is_valid_antecedent("dégénérescence maculaire liée à l'âge")
# --- Cas limites ---
def test_reject_too_short(self):
assert not _is_valid_antecedent("de Bo")
@@ -541,3 +579,108 @@ class TestClassifierConfidence:
result = classify(text)
assert isinstance(result, str)
assert result in ("crh", "trackare")
class TestBackwardCompatAntecedent:
"""Tests de rétrocompatibilité pour les antécédents et complications."""
def test_old_format_string_list(self):
"""Charger un vieux JSON avec antecedents: ["HTA", "Diabète"]."""
d = DossierMedical.model_validate({
"antecedents": ["HTA", "Diabète type 2"],
"complications": ["Fièvre"],
})
assert len(d.antecedents) == 2
assert isinstance(d.antecedents[0], Antecedent)
assert d.antecedents[0].texte == "HTA"
assert d.antecedents[1].texte == "Diabète type 2"
assert len(d.complications) == 1
assert isinstance(d.complications[0], Complication)
assert d.complications[0].texte == "Fièvre"
def test_new_format_object_list(self):
"""Charger un nouveau JSON avec antecedents: [{texte: "HTA", source_page: 1}]."""
d = DossierMedical.model_validate({
"antecedents": [{"texte": "HTA", "source_page": 2, "source_excerpt": "contexte HTA"}],
"complications": [{"texte": "Fièvre", "source_page": 3}],
})
assert d.antecedents[0].texte == "HTA"
assert d.antecedents[0].source_page == 2
assert d.antecedents[0].source_excerpt == "contexte HTA"
assert d.complications[0].source_page == 3
def test_mixed_format(self):
"""Un mélange de strings et d'objets est converti correctement."""
d = DossierMedical.model_validate({
"antecedents": ["HTA", {"texte": "Diabète", "source_page": 1}],
})
assert len(d.antecedents) == 2
assert d.antecedents[0].texte == "HTA"
assert d.antecedents[0].source_page is None
assert d.antecedents[1].texte == "Diabète"
assert d.antecedents[1].source_page == 1
def test_empty_list(self):
d = DossierMedical.model_validate({"antecedents": [], "complications": []})
assert d.antecedents == []
assert d.complications == []
def test_antecedent_extraction_produces_objects(self):
"""L'extraction produit bien des objets Antecedent."""
parsed = {
"type": "crh",
"patient": {"sexe": "M"},
"sejour": {},
"diagnostics": [],
}
text = "Antécédents :\n- Diabète type 2\n- Hypertension artérielle\n\nHistoire de la maladie"
dossier = extract_medical_info(parsed, text)
assert len(dossier.antecedents) >= 1
assert all(isinstance(a, Antecedent) for a in dossier.antecedents)
textes = [a.texte for a in dossier.antecedents]
assert "Diabète type 2" in textes
def test_complication_extraction_produces_objects(self):
"""L'extraction produit bien des objets Complication."""
parsed = {
"type": "crh",
"patient": {"sexe": "M"},
"sejour": {},
"diagnostics": [],
}
text = "Patient avec fièvre post-opératoire."
dossier = extract_medical_info(parsed, text)
assert all(isinstance(c, Complication) for c in dossier.complications)
class TestSourceTrackingFields:
"""Tests que les champs source_page/source_excerpt existent sur les modèles."""
def test_biologie_source_fields(self):
from src.config import BiologieCle
b = BiologieCle(test="CRP", valeur="45", source_page=2, source_excerpt="CRP=45")
assert b.source_page == 2
assert b.source_excerpt == "CRP=45"
def test_imagerie_source_fields(self):
from src.config import Imagerie
i = Imagerie(type="TDM", source_page=3)
assert i.source_page == 3
def test_traitement_source_fields(self):
from src.config import Traitement
t = Traitement(medicament="Paracétamol", source_page=4)
assert t.source_page == 4
def test_acte_source_fields(self):
from src.config import ActeCCAM
a = ActeCCAM(texte="Cholécystectomie", source_page=5)
assert a.source_page == 5
def test_antecedent_source_fields(self):
a = Antecedent(texte="HTA", source_page=1, source_excerpt="Antécédents: HTA")
assert a.source_page == 1
def test_complication_source_fields(self):
c = Complication(texte="Fièvre", source_page=2)
assert c.source_page == 2

View File

@@ -143,3 +143,15 @@ class TestDetailPageLoads:
"""Un fichier inexistant retourne 404."""
response = client.get("/dossier/nonexistent.json")
assert response.status_code == 404
class TestSourceTextEndpoint:
def test_source_text_404_nonexistent(self, client):
"""Un dossier inexistant retourne 404."""
response = client.get("/api/source-text/nonexistent_dossier")
assert response.status_code == 404
def test_source_text_security_path_traversal(self, client):
"""Path traversal bloqué."""
response = client.get("/api/source-text/../../etc")
assert response.status_code in (403, 404)