feat: architecture multi-modèles LLM + quality engine + benchmark
- Multi-modèles : 4 rôles LLM (coding=gemma3:27b-cloud, cpam=gemma3:27b-cloud, validation=deepseek-v3.2:cloud, qc=gemma3:12b) avec get_model(role) - Prompts externalisés : 7 templates dans src/prompts/templates.py - Cache Ollama : modèle stocké par entrée (migration auto ancien format) - call_ollama() : paramètre role= (priorité: model > role > global) - Quality engine : veto_engine + decision_engine + rules_router (YAML) - Benchmark qualité : scripts/benchmark_quality.py (A/B, métriques CIM-10) - Fix biologie : valeurs qualitatives (troponine négative) non filtrées - Fix CPAM : gemma3:27b-cloud au lieu de deepseek (JSON tronqué par thinking) - CPAM max_tokens 4000→6000, viewer admin multi-modèles - Benchmark 10 dossiers : 100% DAS valides, 10/10 CPAM, 243s/dossier Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -289,7 +289,7 @@
|
||||
<h3>Diagnostic principal</h3>
|
||||
<div style="font-size:0.95rem;margin-bottom:0.5rem;">
|
||||
{{ dp.texte }}
|
||||
{% if dp.source_page %}<button class="src-btn" data-excerpt="{{ dp.source_excerpt|default('',true)|e }}" data-page="{{ dp.source_page }}">p.{{ dp.source_page }}</button>{% endif %}
|
||||
{% if dp.source_page %}<button class="src-btn" data-texte="{{ dp.texte|e }}" data-excerpt="{{ dp.source_excerpt|default('',true)|e }}" data-page="{{ 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>
|
||||
@@ -358,7 +358,7 @@
|
||||
<span class="badge" style="background:#e0e7ff;color:#3730a3;font-size:0.7rem;">{{ das.source }}</span>
|
||||
{% endif %}
|
||||
{% if das.source_page %}
|
||||
<button class="src-btn" data-excerpt="{{ das.source_excerpt|default('',true)|e }}" data-page="{{ das.source_page }}">p.{{ das.source_page }}</button>
|
||||
<button class="src-btn" data-texte="{{ das.texte|e }}" data-excerpt="{{ das.source_excerpt|default('',true)|e }}" data-page="{{ das.source_page }}">p.{{ das.source_page }}</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="font-size:0.8rem;color:#475569;">
|
||||
@@ -430,7 +430,7 @@
|
||||
<div style="font-size:0.7rem;color:#dc2626;">{{ alerte }}</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>{% if a.source_page %}<button class="src-btn" data-excerpt="{{ a.source_excerpt|default('',true)|e }}" data-page="{{ a.source_page }}">p.{{ a.source_page }}</button>{% endif %}</td>
|
||||
<td>{% if a.source_page %}<button class="src-btn" data-texte="{{ a.texte|e }}" data-excerpt="{{ a.source_excerpt|default('',true)|e }}" data-page="{{ a.source_page }}">p.{{ a.source_page }}</button>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -450,7 +450,7 @@
|
||||
<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" data-excerpt="{{ b.source_excerpt|default('',true)|e }}" data-page="{{ b.source_page }}">p.{{ b.source_page }}</button>{% endif %}</td>
|
||||
<td>{% if b.source_page %}<button class="src-btn" data-texte="{{ b.test|e }}" data-excerpt="{{ b.source_excerpt|default('',true)|e }}" data-page="{{ b.source_page }}">p.{{ b.source_page }}</button>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -466,7 +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" data-excerpt="{{ img.source_excerpt|default('',true)|e }}" data-page="{{ img.source_page }}">p.{{ img.source_page }}</button>{% endif %}
|
||||
{% if img.source_page %}<button class="src-btn" data-texte="{{ img.type|e }}" data-excerpt="{{ img.source_excerpt|default('',true)|e }}" data-page="{{ img.source_page }}">p.{{ img.source_page }}</button>{% endif %}
|
||||
{% if img.conclusion %}
|
||||
<div style="font-size:0.85rem;color:#475569;">{{ img.conclusion }}</div>
|
||||
{% endif %}
|
||||
@@ -487,7 +487,7 @@
|
||||
<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" data-excerpt="{{ t.source_excerpt|default('',true)|e }}" data-page="{{ t.source_page }}">p.{{ t.source_page }}</button>{% endif %}</td>
|
||||
<td>{% if t.source_page %}<button class="src-btn" data-texte="{{ t.medicament|e }}" data-excerpt="{{ t.source_excerpt|default('',true)|e }}" data-page="{{ t.source_page }}">p.{{ t.source_page }}</button>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -501,7 +501,7 @@
|
||||
<h3>Antécédents ({{ dossier.antecedents|length }})</h3>
|
||||
<ul class="bullet">
|
||||
{% for a in dossier.antecedents %}
|
||||
<li>{{ a.texte }}{% if a.source_page %} <button class="src-btn" data-excerpt="{{ a.source_excerpt|default('',true)|e }}" data-page="{{ a.source_page }}">p.{{ a.source_page }}</button>{% endif %}</li>
|
||||
<li>{{ a.texte }}{% if a.source_page %} <button class="src-btn" data-texte="{{ a.texte|e }}" data-excerpt="{{ a.source_excerpt|default('',true)|e }}" data-page="{{ a.source_page }}">p.{{ a.source_page }}</button>{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -513,7 +513,7 @@
|
||||
<h3>Complications ({{ dossier.complications|length }})</h3>
|
||||
<ul class="bullet">
|
||||
{% for c in dossier.complications %}
|
||||
<li>{{ c.texte }}{% if c.source_page %} <button class="src-btn" data-excerpt="{{ c.source_excerpt|default('',true)|e }}" data-page="{{ c.source_page }}">p.{{ c.source_page }}</button>{% endif %}</li>
|
||||
<li>{{ c.texte }}{% if c.source_page %} <button class="src-btn" data-texte="{{ c.texte|e }}" data-excerpt="{{ c.source_excerpt|default('',true)|e }}" data-page="{{ c.source_page }}">p.{{ c.source_page }}</button>{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -536,36 +536,109 @@
|
||||
<script>
|
||||
/* --- Source modal --- */
|
||||
let _sourceCache = null;
|
||||
|
||||
function getDossierId() {
|
||||
// filepath = "103_23056749/103_23056749_fusionne_cim10.json"
|
||||
// dossier_id = "103_23056749"
|
||||
const _dossierId = (function() {
|
||||
const fp = {{ filepath|tojson }};
|
||||
const parts = fp.split('/');
|
||||
return parts.length > 1 ? parts.slice(0, -1).join('/') : '';
|
||||
}
|
||||
})();
|
||||
const _sourceFiles = {{ dossier.source_files|tojson }};
|
||||
|
||||
function getDossierId() { return _dossierId; }
|
||||
|
||||
async function loadSourceTexts() {
|
||||
if (_sourceCache !== null) return _sourceCache;
|
||||
const dossierId = getDossierId();
|
||||
if (!dossierId) { _sourceCache = {}; return _sourceCache; }
|
||||
if (!_dossierId) { _sourceCache = {}; return _sourceCache; }
|
||||
try {
|
||||
const resp = await fetch('/api/source-text/' + dossierId);
|
||||
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) {
|
||||
/* Teste si le PDF caviardé est disponible (HEAD request) */
|
||||
async function pdfAvailable(dossierId, filename) {
|
||||
try {
|
||||
const resp = await fetch('/api/pdf/' + dossierId + '/' + encodeURIComponent(filename), {method: 'HEAD'});
|
||||
return resp.ok;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
/* Construit l'URL du PDF avec highlight + page */
|
||||
function buildPdfUrl(dossierId, filename, page, excerpt) {
|
||||
let url = '/api/pdf/' + dossierId + '/' + encodeURIComponent(filename);
|
||||
const params = [];
|
||||
if (excerpt) params.push('highlight=' + encodeURIComponent(excerpt));
|
||||
if (page) params.push('page=' + page);
|
||||
if (params.length) url += '?' + params.join('&');
|
||||
url += '#page=' + (page || 1);
|
||||
return url;
|
||||
}
|
||||
|
||||
/* Affiche un PDF dans l'iframe */
|
||||
function loadPdf(dossierId, filename, page, excerpt) {
|
||||
const content = document.getElementById('source-content');
|
||||
const url = buildPdfUrl(dossierId, filename, page, excerpt);
|
||||
content.className = 'source-content-pdf';
|
||||
content.innerHTML = '<iframe src="' + url + '" style="width:100%;height:100%;border:none;"></iframe>';
|
||||
// Marquer le bouton actif
|
||||
document.querySelectorAll('.src-file-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.src-file-btn').forEach(b => {
|
||||
if (b.textContent === filename) b.classList.add('active');
|
||||
});
|
||||
}
|
||||
|
||||
/* Affiche le modal source — PDF caviardé si disponible, sinon fallback texte */
|
||||
async function showSource(excerpt, page, texte) {
|
||||
// Pour le surlignage PDF, on utilise le texte du diagnostic (pas l'excerpt brut)
|
||||
const highlightText = texte || excerpt;
|
||||
const modal = document.getElementById('source-modal');
|
||||
const modalInner = document.getElementById('source-modal-inner');
|
||||
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>';
|
||||
content.className = '';
|
||||
modalInner.className = '';
|
||||
modal.style.display = 'block';
|
||||
|
||||
// Essayer le mode PDF
|
||||
if (_sourceFiles && _sourceFiles.length > 0 && _dossierId) {
|
||||
const firstFile = _sourceFiles[0];
|
||||
const available = await pdfAvailable(_dossierId, firstFile);
|
||||
if (available) {
|
||||
modalInner.className = '';
|
||||
if (_sourceFiles.length === 1) {
|
||||
loadPdf(_dossierId, firstFile, page, highlightText);
|
||||
} else {
|
||||
// Multi-PDF : boutons de sélection + iframe
|
||||
const safeHighlight = (highlightText || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
let html = '<div style="padding:0.5rem 0.75rem;border-bottom:1px solid #e2e8f0;display:flex;gap:0.5rem;flex-wrap:wrap;">';
|
||||
_sourceFiles.forEach(function(f) {
|
||||
const safeF = f.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
html += '<button class="src-file-btn" onclick="loadPdf(\'' + _dossierId + '\', \'' + safeF + '\', ' + page + ', \'' + safeHighlight + '\')">' + f + '</button>';
|
||||
});
|
||||
html += '</div>';
|
||||
html += '<iframe id="pdf-frame" style="width:100%;flex:1;border:none;"></iframe>';
|
||||
content.className = 'source-content-pdf';
|
||||
content.style.display = 'flex';
|
||||
content.style.flexDirection = 'column';
|
||||
content.innerHTML = html;
|
||||
// Charger le premier PDF
|
||||
const iframe = content.querySelector('iframe');
|
||||
iframe.src = buildPdfUrl(_dossierId, firstFile, page, highlightText);
|
||||
content.querySelector('.src-file-btn').classList.add('active');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback : mode texte (ancien comportement)
|
||||
modalInner.className = 'source-modal-text';
|
||||
content.className = '';
|
||||
content.style.display = '';
|
||||
|
||||
const texts = await loadSourceTexts();
|
||||
const allText = Object.values(texts).join('\n\n--- ---\n\n');
|
||||
|
||||
@@ -583,7 +656,6 @@ async function showSource(excerpt, page) {
|
||||
// Chercher l'extrait dans le texte et le surligner
|
||||
if (searchText.length > 10) {
|
||||
let idx = allText.indexOf(searchText);
|
||||
// Fallback : chercher un morceau central (résiste mieux à l'anonymisation)
|
||||
if (idx < 0 && searchText.length > 60) {
|
||||
const mid = Math.floor(searchText.length / 2);
|
||||
searchText = searchText.substring(mid - 30, mid + 30);
|
||||
@@ -600,7 +672,6 @@ async function showSource(excerpt, page) {
|
||||
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' });
|
||||
@@ -609,11 +680,15 @@ async function showSource(excerpt, page) {
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback : afficher le texte brut sans surlignage
|
||||
content.textContent = allText;
|
||||
}
|
||||
|
||||
function closeSource() {
|
||||
const content = document.getElementById('source-content');
|
||||
// Détruire l'iframe pour stopper le chargement PDF
|
||||
content.innerHTML = '';
|
||||
content.style.display = '';
|
||||
content.className = '';
|
||||
document.getElementById('source-modal').style.display = 'none';
|
||||
}
|
||||
|
||||
@@ -631,7 +706,7 @@ document.addEventListener('keydown', function(e) {
|
||||
document.addEventListener('click', function(e) {
|
||||
const btn = e.target.closest('.src-btn');
|
||||
if (btn && btn.dataset.page) {
|
||||
showSource(btn.dataset.excerpt || '', parseInt(btn.dataset.page));
|
||||
showSource(btn.dataset.excerpt || '', parseInt(btn.dataset.page), btn.dataset.texte || '');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user