"use strict";
/* Kazeia-central — shell d'application (clinique-apaisé, sans build). */
const S = { initialized: false, unlocked: false, devices: [], selected: null, section: "flotte" };
// ---- helpers --------------------------------------------------------------
const $ = (s, r = document) => r.querySelector(s);
function elFrom(html) { const t = document.createElement("template"); t.innerHTML = html.trim(); return t.content.firstChild; }
async function api(path, opts) {
const r = await fetch("/api" + path, opts);
if (!r.ok) { let d = r.status; try { d = (await r.json()).detail || d; } catch {} const e = new Error(d); e.status = r.status; throw e; }
return r.status === 204 ? null : r.json();
}
function toast(msg, kind = "info", ms = 3800) {
const t = elFrom(`
${msg}
`); $("#toasts").appendChild(t);
setTimeout(() => t.remove(), ms);
}
const esc = (s) => (s == null ? "" : String(s)).replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
const badge = (txt, cls = "") => `${esc(txt)} `;
const dot = (cls) => ` `;
const fmtTs = (ms) => ms ? new Date(ms).toLocaleString("fr-FR") : "—";
const scopeBadge = (s) => badge(s || "?", { exclusive: "warn", global: "ok", pending: "" }[s] || "");
function deviceLabel(serial) { const d = S.devices.find(x => x.serial === serial); return d && d.label ? d.label : serial; }
async function withBusy(btn, fn) { const o = btn.textContent; btn.disabled = true; btn.innerHTML = ` `; try { return await fn(); } finally { btn.disabled = false; btn.textContent = o; } }
// ---- sections -------------------------------------------------------------
const SECTIONS = [
{ group: "🔧 Technique", domain: "tech" },
{ id: "flotte", ic: "🛰", label: "Flotte", render: renderFlotte },
{ id: "voix", ic: "🎙", label: "Voix", render: renderVoix },
{ id: "updates", ic: "⬆", label: "Mises à jour", render: renderUpdates },
{ id: "config", ic: "⚙", label: "Config & moteur", render: () => stub("Config & moteur", "Config sampling (température, presets…) et prompts moteur, avec push par tablette. Dépend des méthodes call() base64-JSON côté app patiente (PROVIDER_RPC_SPEC).") },
{ id: "audit", ic: "📋", label: "Audit", render: renderAudit },
{ group: "⚕️ Médical / clinique", domain: "med" },
{ id: "patients", ic: "👤", label: "Patients & profils", render: renderPatients },
{ id: "conversations", ic: "💬", label: "Conversations", render: renderConversations },
{ id: "rag", ic: "📚", label: "RAG thérapeutique", render: renderRag },
{ id: "questionnaires", ic: "📝", label: "Questionnaires", render: renderQuestionnaires },
];
// ---- boot + shell ---------------------------------------------------------
boot();
async function boot() {
try { const st = await api("/lock-status"); S.initialized = st.initialized; S.unlocked = st.unlocked; }
catch (e) { $("#app").innerHTML = `Kazeia· central
serveur injoignable : ${esc(e.message)}
`; return; }
if (!S.unlocked) return renderGate();
await renderShell();
}
function renderGate() {
const first = !S.initialized;
$("#app").innerHTML = `
Kazeia· central
${first ? "Choisis un mot de passe opérateur (première fois)" : "Déverrouille le coffre opérateur"}
${first ? "Initialiser" : "Déverrouiller"}
`;
const go = async () => {
try { await api("/unlock", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ password: $("#pw").value }) }); S.unlocked = true; await renderShell(); }
catch (e) { $("#gerr").textContent = e.message; }
};
$("#gobtn").onclick = go;
$("#pw").onkeydown = (e) => { if (e.key === "Enter") go(); };
}
async function renderShell() {
$("#app").innerHTML = `
Kazeia· central
Tablette :
🔓 coffre ouvert
verrouiller
`;
$("#sidebar").innerHTML = SECTIONS.map(s => s.group
? `${s.group}
`
: `${s.ic} ${s.label}
`).join("");
$("#sidebar").querySelectorAll(".nav-item").forEach(n => n.onclick = () => { S.section = n.dataset.id; renderSection(); });
await loadDevices();
renderSection();
}
async function loadDevices() {
try { S.devices = await api("/devices"); } catch (e) { S.devices = []; toast("erreur adb : " + e.message, "err"); }
if (S.devices.length && !S.selected) S.selected = S.devices[0].serial;
const sel = $("#devsel");
if (sel) {
sel.innerHTML = `— aucune — ` + S.devices.map(d =>
`${esc(d.label || d.serial)} ${d.state !== "device" ? "(" + d.state + ")" : ""} `).join("");
sel.onchange = () => { S.selected = sel.value || null; renderSection(); };
}
}
function renderSection() {
document.querySelectorAll(".nav-item").forEach(n => n.classList.toggle("active", n.dataset.id === S.section));
const sec = SECTIONS.find(s => s.id === S.section) || SECTIONS.find(s => s.render);
const c = $("#content"); c.innerHTML = `
`;
Promise.resolve(sec.render(c)).catch(e => { c.innerHTML = ``; });
}
function pageHead(title, sub, actions = "") {
return `${title} ${sub ? `
${sub}
` : ""}
${actions}
`;
}
function stub(title, desc) {
return `${pageHead(title)}🚧
${esc(desc)}
Section prévue par l'architecture — non encore implémentée.
`;
}
// ---- FLOTTE ---------------------------------------------------------------
async function renderFlotte(c) {
const rep = await api("/fleet/overview").catch(e => ({ error: e.message }));
const rows = Array.isArray(rep) ? rep : [];
const head = pageHead("Flotte", `${S.devices.length} tablette(s)`,
`↻ Rafraîchir `);
const table = rep.error ? `${esc(rep.error)}
` : rows.length ? `
tablette état RAM MAJ RAG profils sessions crashes
${rows.map(r => { const o = r.result || {}; const sel = r.serial === S.selected;
return `
${esc(r.label || r.serial)} ${esc(r.serial)}
${r.ok === false ? `${esc(r.error)} ` : `
${dot(o.online ? "ok" : "off")} ${o.online ? "en ligne" : "hors-ligne"}
${o.mem_available_mb ?? "—"} Mo
${esc(o.update_phase || "—")}${o.app_update_available ? " ⬆" : ""}
${dot(o.rag_ready ? "ok" : "idle")} ${o.rag_docs ?? 0}
${o.profiles ?? 0} ${o.sessions ?? 0}
${o.crashes ? `${o.crashes} ` : 0} `} `; }).join("")}
` : `aucune tablette branchée
`;
c.innerHTML = head + `${table}
`;
if (S.selected) renderDeviceDetail($("#devdetail"), S.selected);
}
function selectDevice(serial) { S.selected = serial; const sel = $("#devsel"); if (sel) sel.value = serial; renderSection(); }
async function renderDeviceDetail(host, serial) {
host.innerHTML = `
Détail · ${esc(deviceLabel(serial))} `;
const get = (p, fn) => api(`/devices/${serial}/${p}`).then(fn).catch(e => ``);
const cards = await Promise.all([
get("state", s => card("État runtime", kv("pid", s.pid) + kv("uptime (s)", s.uptime_seconds) + kv("PSS (Mo)", s.pss_mb) + kv("RAM dispo (Mo)", s.mem_available_mb))),
get("updates", u => card("Mises à jour", kv("phase", u.phase) + kv("MAJ dispo", u.app_update_available ? "oui" : "non") + kv("version", u.app_version_name || "—"))),
get("rag/status", r => card("RAG", kv("état", (r.ready ? "prêt" : "non prêt")) + kv("modèle", r.model || "—") + kv("docs", r.doc_count ?? 0))),
get("profiles", ps => card("Profils (" + ps.length + ")", ps.length ? `nom voix PIN tours ${ps.map(p => `${esc(p.display_name || p.id)} ${esc(p.voice_id || "—")} ${p.has_pin ? "🔒" : "—"} ${p.turn_count ?? 0} `).join("")}
` : `aucun `, "full")),
get("conversations", cs => card("Conversations (" + cs.length + ")", cs.length ? `profil session tours début ${cs.map(x => `${esc(x.profile_id)} ${esc(x.session_id)} ${x.turn_count ?? 0} ${fmtTs(x.started_at)} `).join("")}
` : `aucune session `, "full")),
get("crashes", cr => card("Crashes (" + cr.length + ")", cr.length ? cr.map(x => kv(x.component || "?", x.message || "")).join("") : `aucun `)),
]);
$("#ddgrid").innerHTML = cards.join("");
}
const card = (title, body, cls = "") => `
${title} ${body}`;
const kv = (k, v) => `${k} ${v ?? "—"}
`;
// ---- MISES À JOUR ---------------------------------------------------------
async function renderUpdates(c) {
const rep = await api("/fleet/overview").catch(e => ({ error: e.message }));
const rows = Array.isArray(rep) ? rep : [];
c.innerHTML = pageHead("Mises à jour", "OTA piloté par tablette",
`Vérifier le parc Installer le parc `)
+ `${rep.error ? `
${esc(rep.error)}
` : `
tablette phase MAJ dispo version
${rows.map(r => { const o = r.result || {}; return `${esc(r.label || r.serial)} ${esc(o.update_phase || "—")} ${o.app_update_available ? badge("disponible", "warn") : "—"} ${esc(o.app_version_name || "—")} `; }).join("")}
`}
`;
$("#chk").onclick = (e) => withBusy(e.target, async () => { const r = await api("/fleet/update-check", { method: "POST" }); toast(`${r.filter(x => x.ok).length}/${r.length} ont accepté la vérification`, "ok"); });
$("#inst").onclick = (e) => { if (!confirm("Lancer l'installation OTA sur tout le parc ?")) return; withBusy(e.target, async () => { const r = await api("/fleet/update-install", { method: "POST" }); toast(`installation lancée sur ${r.filter(x => x.ok).length}/${r.length}`, "ok"); }); };
}
// ---- AUDIT ----------------------------------------------------------------
async function renderAudit(c) {
const log = await api("/audit").catch(e => ({ error: e.message }));
const rows = Array.isArray(log) ? log : [];
c.innerHTML = pageHead("Audit", "piste d'accès opérateur (§8)") + `${log.error ? `
${esc(log.error)}
` : rows.length ? `
date acteur action cible
${rows.map(a => `${fmtTs(a.ts)} ${esc(a.actor || "—")} ${badge(a.action, "primary")} ${esc(a.target || "—")} `).join("")}
` : `
aucune entrée
`}
`;
}
// ---- VOIX -----------------------------------------------------------------
async function renderVoix(c) {
const serial = S.selected;
const reqs = [api("/voices/catalog").catch(e => ({ error: e.message })), api("/voices/autosync").catch(e => ({ error: e.message }))];
if (serial) reqs.push(api(`/voices/${serial}`).catch(e => ({ error: e.message })), api(`/voices/${serial}/admin`).catch(e => ({ error: e.message })));
const [cat, auto, tablet, admin] = await Promise.all(reqs);
const cv = Array.isArray(cat) ? cat : [];
const catCard = card(`Catalogue voix — flotte (${cv.length})`, cat.error ? `${esc(cat.error)}
` :
cv.length ? `voix portée verrou déployée sur
${cv.map(v => `${esc(v.name || v.voice_id)} ${esc(v.voice_id)}
${scopeBadge(v.scope)}
${v.locked_profile_id ? "🔒 " + esc(v.owner_name || v.locked_profile_id) : `généraliste `}
${(v.deployed_serials || []).map(deviceLabel).map(esc).join(", ") || "—"}
${v.locked_profile_id ? `déverrouiller ` : `verrouiller `}
${v.has_wav ? `ré-enrôler ` : ""} `).join("")}
` : `catalogue vide — enregistre des voix via l'app admin puis synchronise
`, "full");
const autoCard = card("Déclencheur automatique", auto.error ? `${esc(auto.error)}
` :
`état ${dot(auto.enabled ? "ok" : "idle")} ${auto.enabled ? "armé" : "désarmé"}
${auto.enabled ? "Désarmer" : "Armer"}
supprimer le WAV device après
`);
let cards = catCard + autoCard;
if (!serial) {
cards += card("Par tablette", `Choisis une tablette (en haut) pour voir ce qui y est chargé et les voix chargeables dessus.
`, "full");
} else {
const av = Array.isArray(admin) ? admin : [];
const adminCard = card(`Voix admin à enrôler — ${esc(deviceLabel(serial))} (${av.length})`, admin.error ? `${esc(admin.error)}
` :
(av.length ? `nom portée propriétaire statut ${av.map(v => `${esc(v.name || v.voice_id)} ${scopeBadge(v.scope)} ${esc(v.owner_name || v.owner_profile_id || "—")} ${esc(v.status)} `).join("")}
` : `aucune voix admin en attente
`)
+ `Synchroniser (enrôler + déployer) supprimer le WAV device après
`, "full");
const loaded = (tablet && tablet.deployed) || [], loadable = (tablet && tablet.deployable) || [];
const tabCard = card(`${esc(deviceLabel(serial))} — chargées (${loaded.length}) · chargeables (${loadable.filter(v => v.deployable).length})`, tablet && tablet.error ? `${esc(tablet.error)}
` :
`voix portée état
${loaded.map(v => `${dot("ok")} ${esc(v.name || v.voice_id)}${v.in_catalog === false ? ` (hors catalogue) ` : ""} ${v.scope ? scopeBadge(v.scope) : "—"} chargée ${v.in_catalog !== false ? `retirer ` : ""} `).join("")}
${loadable.map(v => `${esc(v.name || v.voice_id)} ${scopeBadge(v.scope)} ${v.deployable ? "chargeable" : esc(v.reason || "—")} ${v.deployable ? `charger ` : ""} `).join("")}
`, "full");
cards += adminCard + tabCard;
}
c.innerHTML = pageHead("Voix", "catalogue de flotte · enrôlement OmniVoice") + `${cards}
`;
}
// ---- actions voix ---------------------------------------------------------
async function toggleAutosync(on) { try { await api("/voices/autosync", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ enabled: on, delete_source: $("#auto_del") && $("#auto_del").checked || false }) }); toast(on ? "auto-enrôlement armé" : "désarmé", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }
async function syncAdmin(btn, serial) { const del = $("#sync_del") && $("#sync_del").checked || false; await withBusy(btn, async () => { try { const r = await api(`/voices/${serial}/admin/sync`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ delete_source: del }) }); toast(`${r.filter(x => x.ok && x.deployed).length}/${r.length} voix déployée(s)`, "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); }
async function deployVoice(btn, serial, vid) { await withBusy(btn, async () => { try { const r = await api(`/voices/${serial}/deploy`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ voice_id: vid }) }); r.deployed ? toast("voix chargée", "ok") : toast("non chargée : " + (r.reason || ""), "err"); renderSection(); } catch (e) { toast(e.message, "err"); } }); }
async function undeployVoice(btn, serial, vid) { await withBusy(btn, async () => { try { await api(`/voices/${serial}/undeploy`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ voice_id: vid }) }); toast("voix retirée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); }
async function reenrollVoice(btn, vid) { if (!confirm(`Ré-enrôler « ${vid} » (fix §7) et re-pousser sur ses tablettes ?`)) return; await withBusy(btn, async () => { try { const r = await api(`/voices/catalog/${vid}/reenroll`, { method: "POST" }); toast("ré-enrôlé · re-déployé : " + ((r.redeployed || []).join(", ") || "—"), "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }); }
async function lockVoicePrompt(vid) { const p = prompt(`Verrouiller « ${vid} » sur quel profil patient ? (profile_id)`); if (!p) return; try { await api(`/voices/catalog/${vid}/lock`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ profile_id: p }) }); toast("voix verrouillée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }
async function unlockVoice(vid) { try { await api(`/voices/catalog/${vid}/unlock`, { method: "POST" }); toast("voix déverrouillée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } }
// ---- PATIENTS (médical) ---------------------------------------------------
const LANGS = ["fr", "en", "de", "es", "it", "pt", "nl", "ar", "zh", "ja", "ko"];
async function renderPatients(c) {
const serial = S.selected;
if (!serial) { c.innerHTML = pageHead("Patients & profils") + `👤
Choisis une tablette (en haut) pour voir ses patients.
`; return; }
const list = await api(`/patients/${serial}`).catch(e => ({ error: e.message }));
const ps = Array.isArray(list) ? list : [];
const table = list.error ? `${esc(list.error)}
` : ps.length ? `
patient voix langue fiche tours
${ps.map(p => `${esc(p.full_name || p.display_name || p.profile_id)} ${esc(p.profile_id)}
${esc(p.voice_id || "—")} ${p.language ? badge(p.language, "primary") : `— `}
${p.has_fiche ? badge("remplie", "ok") : `vide `} ${p.turn_count ?? 0}
ouvrir `).join("")}
` : `aucun profil patient sur cette tablette
`;
c.innerHTML = pageHead("Patients & profils", esc(deviceLabel(serial))) + `${table}
`;
}
const finput = (id, label, val) => `${label} `;
const ftext = (id, label, val) => ``;
const subhead = (t) => `${t}
`;
async function openPatient(serial, pid) {
const host = $("#patdetail"); host.innerHTML = `
`;
let d; try { d = await api(`/patients/${serial}/${pid}`); } catch (e) { host.innerHTML = ``; return; }
const prof = d.profile || {}, f = d.fiche || {}, civ = f.civil || {}, con = f.contact || {}, cli = f.clinical || {};
const curLang = d.language || prof.language || "fr";
const qres = await api(`/questionnaires/results/${serial}/${pid}`).catch(() => []);
const qresCard = card("Questionnaires cliniques chiffrés · PC ",
(Array.isArray(qres) && qres.length) ? `questionnaire date score interprétation
${qres.map(r => `${esc(r.questionnaire_id)} ${fmtTs(r.completed_at)}
${r.result.total_score ?? "—"} ${r.result.interpretation ? badge(r.result.interpretation, "primary") : "—"} `).join("")}
` : `aucun résultat — assigne/saisis un questionnaire dans la section Questionnaires
`, "full");
host.innerHTML = `
Patient · ${esc(prof.display_name || pid)}
${card("Profil on-device (tablette)", kv("nom affiché", esc(prof.display_name || "—")) + kv("voix", esc(prof.voice_id || "—")) + kv("PIN", prof.has_pin ? "🔒 défini" : "—")
+ `
langue du pipeline ${LANGS.map(l => `${l} `).join("")} pousser
`
+ (prof.system_prompt_override ? `
prompt override : ${esc((prof.system_prompt_override || "").slice(0, 90))}…
` : ""))}
${card("Fiche clinique
chiffrée · PC uniquement ",
subhead("Identité civile") + finput("civ_name", "Nom complet", civ.full_name) + finput("civ_birth", "Naissance", civ.birth_date) + finput("civ_sex", "Sexe", civ.sex)
+ subhead("Contact / référent") + finput("con_ref", "Soignant référent", con.referent) + finput("con_fam", "Proche / famille", con.family) + finput("con_tel", "Téléphone", con.phone)
+ subhead("Clinique") + finput("cli_path", "Pathologie / contexte", cli.pathology) + finput("cli_treat", "Traitement", cli.treatment) + finput("cli_all", "Allergies", cli.allergies) + ftext("cli_notes", "Notes cliniques", cli.notes)
+ `
Enregistrer la fiche
`, "full")}
${qresCard}
`;
}
async function saveFiche(btn, serial, pid) {
const gv = (id) => (($("#" + id) || {}).value || "").trim();
const fiche = {
civil: { full_name: gv("civ_name"), birth_date: gv("civ_birth"), sex: gv("civ_sex") },
contact: { referent: gv("con_ref"), family: gv("con_fam"), phone: gv("con_tel") },
clinical: { pathology: gv("cli_path"), treatment: gv("cli_treat"), allergies: gv("cli_all"), notes: gv("cli_notes") },
};
const language = ($("#lang") || {}).value || null;
await withBusy(btn, async () => { try { await api(`/patients/${serial}/${pid}/fiche`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ fiche, language }) }); toast("fiche enregistrée (chiffrée)", "ok"); } catch (e) { toast(e.message, "err"); } });
}
async function pushLanguage(btn, serial, pid) {
const language = ($("#lang") || {}).value;
await withBusy(btn, async () => { try { await api(`/patients/${serial}/${pid}/language`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ language }) }); toast(`langue « ${language} » poussée sur la tablette`, "ok"); } catch (e) { toast(e.message, "err"); } });
}
// ---- CONVERSATIONS (médical, RGPD) ----------------------------------------
async function renderConversations(c) {
const serial = S.selected;
if (!serial) { c.innerHTML = pageHead("Conversations") + `💬
Choisis une tablette (en haut).
`; return; }
const list = await api(`/conversations/${serial}`).catch(e => ({ error: e.message }));
const ss = Array.isArray(list) ? list : [];
const table = list.error ? `${esc(list.error)}
` : ss.length ? `
session patient tours début
${ss.map(s => `${esc(s.session_id)} ${esc(s.profile_id || "—")} ${s.turns}
${fmtTs(s.started_at)}
lire
JSON
purger `).join("")}
` : `aucune conversation archivée — clique « Rapatrier depuis la tablette »
`;
c.innerHTML = pageHead("Conversations", esc(deviceLabel(serial)) + " · archive clinique chiffrée (RGPD)",
`Rapatrier depuis la tablette `)
+ `${table}
`;
}
async function collectConversations(btn, serial) {
await withBusy(btn, async () => { try {
const r = await api(`/conversations/${serial}/collect`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
toast(`${r.archived} tour(s) archivé(s) sur ${r.exported} exporté(s)${r.purged ? " · fichier device purgé" : ""}`, "ok");
renderSection();
} catch (e) { toast(e.message, "err"); } });
}
async function openSession(serial, sid) {
const host = $("#convview"); host.innerHTML = `
`;
let turns; try { turns = await api(`/conversations/${serial}/${sid}`); } catch (e) { host.innerHTML = ``; return; }
host.innerHTML = `Session ${esc(sid)} Exporter JSON
${turns.length ? turns.map(t => { const me = t.role === "PATIENT";
return `
${me ? "Patient" : "Kazeia"} · ${fmtTs(t.ts)}
${esc(t.text || "")}
`; }).join("") : `
aucun tour
`}
`;
}
function exportSession(serial, sid) { window.open(`/api/conversations/${serial}/${sid}/export`, "_blank"); }
async function purgeSession(btn, serial, sid) {
if (!confirm(`Purger définitivement la session ${sid} de l'archive centrale ? (RGPD, irréversible)`)) return;
await withBusy(btn, async () => { try { const r = await api(`/conversations/${serial}/purge`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ session_id: sid }) }); toast(`${r.deleted} tour(s) purgé(s)`, "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } });
}
// ---- RAG thérapeutique (médical) ------------------------------------------
async function renderRag(c) {
const serial = S.selected;
if (!serial) { c.innerHTML = pageHead("RAG thérapeutique") + `📚
Choisis une tablette (en haut).
`; return; }
const [st, docs] = await Promise.all([
api(`/rag/${serial}/status`).catch(e => ({ error: e.message })),
api(`/rag/${serial}`).catch(e => ({ error: e.message })),
]);
const statusCard = card("État du corpus", st.error ? `${esc(st.error)}
` :
`index ${dot(st.ready ? "ok" : "idle")} ${st.ready ? "prêt" : "non prêt"}
` +
kv("modèle", esc(st.model || "—")) + kv("dimension", st.dim ?? "—") + kv("documents", st.doc_count ?? 0) + kv("chunks", st.chunk_count ?? 0));
const ds = Array.isArray(docs) ? docs : [];
const docsCard = card(`Corpus (${ds.length} fiches)`, docs.error ? `${esc(docs.error)}
` :
(ds.length ? `source caractères chunks
${ds.map(d => `${esc(d.source)} ${d.char_count ?? "—"} ${d.chunk_count ?? "—"}
éditer
supprimer `).join("")}
` : `corpus vide
`) +
`+ Nouvelle fiche
`, "full");
const testCard = card("Test de retrieval", `Tester
`, "full");
c.innerHTML = pageHead("RAG thérapeutique", esc(deviceLabel(serial)) + " · corpus clinique") + `${statusCard}${docsCard}
${testCard}
`;
}
async function newDoc(serial) { showDocEditor(serial, "", ""); }
async function openDoc(serial, src) {
const source = decodeURIComponent(src);
try { const d = await api(`/rag/${serial}/doc/${src}`); showDocEditor(serial, d.source, d.text || ""); }
catch (e) { toast(e.message, "err"); }
}
function showDocEditor(serial, source, text) {
const host = $("#ragedit"); host.className = "card full";
host.innerHTML = `${source ? "Éditer" : "Nouvelle fiche"}
source
Enregistrer Annuler
`;
host.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
async function saveDoc(btn, serial) {
const source = ($("#rsrc").value || "").trim(), text = $("#rtext").value;
if (!source) { toast("nomme la source", "err"); return; }
await withBusy(btn, async () => { try { await api(`/rag/${serial}/doc/${encodeURIComponent(source)}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ text }) }); toast(`fiche « ${source} » enregistrée + ré-indexée`, "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } });
}
async function deleteDoc(btn, serial, src) {
if (!confirm(`Supprimer la fiche « ${decodeURIComponent(src)} » du corpus ?`)) return;
await withBusy(btn, async () => { try { await api(`/rag/${serial}/doc/${src}`, { method: "DELETE" }); toast("fiche supprimée", "ok"); renderSection(); } catch (e) { toast(e.message, "err"); } });
}
async function ragQuery(btn, serial) {
const q = $("#ragq").value.trim(); if (!q) return;
await withBusy(btn, async () => {
try {
const hits = await api(`/rag/${serial}/query`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ text: q }) });
$("#raghits").innerHTML = hits.length ? hits.map(h => `${esc(h.source)} · score ${(h.score ?? 0).toFixed ? h.score.toFixed(3) : h.score}
${esc((h.text || "").slice(0, 220))}${(h.text || "").length > 220 ? "…" : ""}
`).join("") : `aucun résultat
`;
} catch (e) { toast(e.message, "err"); }
});
}
// ---- QUESTIONNAIRES cliniques (médical) -----------------------------------
// Catalogue = niveau FLOTTE (central, indépendant d'une tablette). Assignation +
// résultats = par patient (tablette sélectionnée → profil).
const DEFAULT_SCALE = [
{ label: "Jamais", score: 0 }, { label: "Plusieurs jours", score: 1 },
{ label: "Plus de la moitié des jours", score: 2 }, { label: "Presque tous les jours", score: 3 },
];
let QB = null; // copie de travail du builder QCM
const slugify = (s) => (s || "").toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "q";
async function renderQuestionnaires(c) {
const serial = S.selected;
const list = await api(`/questionnaires`).catch(e => ({ error: e.message }));
const qs = Array.isArray(list) ? list : [];
const catalog = card(`Catalogue clinique (${qs.length})`, list.error ? `${esc(list.error)}
` :
(qs.length ? `questionnaire questions type
${qs.map(q => `${esc(q.title)} ${q.builtin ? badge("fourni", "primary") : ""}${esc(q.id)}
${q.n_questions} QCM
aperçu
${q.builtin ? "dupliquer" : "éditer"}
${q.builtin ? "" : `suppr. `} `).join("")}
` : `aucun questionnaire
`) +
`+ Nouveau questionnaire (QCM)
`, "full");
const assignPanel = card("Assignation & résultats · par patient",
!serial ? `Choisis une tablette (en haut) puis un patient pour assigner un questionnaire et consulter ses résultats.
`
: `Patient
— choisir —
`, "full");
c.innerHTML = pageHead("Questionnaires cliniques", "Échelles QCM — authoring médecin, assignation par patient, scores dans le dossier")
+ ``;
if (serial) {
const ps = await api(`/patients/${serial}`).catch(() => []);
const sel = $("#qpat");
if (sel && Array.isArray(ps)) sel.innerHTML = `— choisir — ` +
ps.map(p => `${esc(p.full_name || p.display_name || p.profile_id)} `).join("");
}
}
// --- builder QCM ---
function newQuestionnaire() {
QB = { id: "", title: "", description: "", builtin: false, scale: DEFAULT_SCALE.map(x => ({ ...x })),
questions: [{ text: "" }], ranges: [{ min: 0, max: 0, label: "" }] };
showQBuilder();
}
async function editQuestionnaire(qid) {
try {
const q = await api(`/questionnaires/${encodeURIComponent(qid)}`); const d = q.definition || {};
const dup = q.builtin; // les fournis se dupliquent (non éditables)
QB = { id: dup ? "" : q.id, title: dup ? q.title + " (copie)" : q.title, description: d.description || "", builtin: false,
scale: (d.scale || DEFAULT_SCALE).map(x => ({ ...x })),
questions: (d.questions || [{ text: "" }]).map(x => ({ text: x.text || "" })),
ranges: ((d.scoring || {}).ranges || []).map(x => ({ ...x })) };
showQBuilder();
} catch (e) { toast(e.message, "err"); }
}
function qbCollect() { // DOM → QB avant toute mutation
if (!QB) return;
QB.title = ($("#qb_title") || {}).value ?? QB.title;
QB.description = ($("#qb_desc") || {}).value ?? QB.description;
QB.scale = QB.scale.map((_, i) => ({ label: ($(`#qb_sl_${i}`) || {}).value || "", score: Number(($(`#qb_ss_${i}`) || {}).value || 0) }));
QB.questions = QB.questions.map((_, i) => ({ text: ($(`#qb_q_${i}`) || {}).value || "" }));
QB.ranges = QB.ranges.map((_, i) => ({ min: Number(($(`#qb_rmin_${i}`) || {}).value || 0), max: Number(($(`#qb_rmax_${i}`) || {}).value || 0), label: ($(`#qb_rl_${i}`) || {}).value || "" }));
}
const qbMut = (fn) => { qbCollect(); fn(); showQBuilder(); };
function qbAddScale() { qbMut(() => QB.scale.push({ label: "", score: QB.scale.length })); }
function qbDelScale(i) { qbMut(() => QB.scale.splice(i, 1)); }
function qbAddQuestion() { qbMut(() => QB.questions.push({ text: "" })); }
function qbDelQuestion(i) { qbMut(() => QB.questions.splice(i, 1)); }
function qbAddRange() { qbMut(() => QB.ranges.push({ min: 0, max: 0, label: "" })); }
function qbDelRange(i) { qbMut(() => QB.ranges.splice(i, 1)); }
function qbCancel() { QB = null; $("#qedit").innerHTML = ""; $("#qedit").className = ""; }
function showQBuilder() {
const host = $("#qedit"); host.className = "card full";
const scaleRows = QB.scale.map((s, i) => `
×
`).join("");
const qRows = QB.questions.map((q, i) => `
${i + 1}
×
`).join("");
const rRows = QB.ranges.map((r, i) => `
→
×
`).join("");
const maxScore = QB.questions.length * Math.max(0, ...QB.scale.map(s => s.score || 0));
host.innerHTML = `${QB.id ? "Éditer" : "Nouveau"} questionnaire QCM
${finput("qb_title", "Titre", QB.title)}${ftext("qb_desc", "Description (consigne au patient)", QB.description)}
${subhead(`Échelle de réponse commune — score max total : ${maxScore}`)}
Chaque question propose ces choix (QCM à choix unique, chaque choix vaut un score).
${scaleRows}+ choix
${subhead(`Questions (${QB.questions.length})`)}${qRows}+ question
${subhead("Interprétation du score total (bandes)")}${rRows}+ bande
Enregistrer
Annuler
`;
host.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
async function saveQuestionnaire(btn) {
qbCollect();
if (!QB.title.trim()) { toast("donne un titre", "err"); return; }
if (!QB.questions.filter(q => q.text.trim()).length) { toast("ajoute au moins une question", "err"); return; }
const qid = QB.id || slugify(QB.title);
const definition = { description: QB.description, type: "qcm", scale: QB.scale.filter(s => s.label.trim()),
questions: QB.questions.filter(q => q.text.trim()), scoring: { method: "sum", ranges: QB.ranges.filter(r => r.label.trim()) } };
await withBusy(btn, async () => { try {
await api(`/questionnaires/${encodeURIComponent(qid)}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ title: QB.title.trim(), definition }) });
toast(`questionnaire « ${QB.title.trim()} » enregistré`, "ok"); QB = null; renderSection();
} catch (e) { toast(e.message, "err"); } });
}
async function delQuestionnaire(btn, qid) {
if (!confirm(`Supprimer le questionnaire « ${qid} » ? (les résultats déjà enregistrés restent dans les dossiers)`)) return;
await withBusy(btn, async () => { try { const r = await api(`/questionnaires/${encodeURIComponent(qid)}`, { method: "DELETE" });
if (r.deleted) { toast("questionnaire supprimé", "ok"); renderSection(); } else toast("questionnaire fourni — non supprimable", "err"); } catch (e) { toast(e.message, "err"); } });
}
async function previewQuestionnaire(qid) {
try {
const q = await api(`/questionnaires/${encodeURIComponent(qid)}`); const d = q.definition || {};
const scale = d.scale || []; const host = $("#qedit"); host.className = "card full";
host.innerHTML = `${esc(q.title)} aperçu
${d.description ? `${esc(d.description)}
` : ""}
${(d.questions || []).map(qq => `${esc(qq.text)}
${(qq.choices || scale).map(ch => `${esc(ch.label)} (${ch.score})`).join(" · ")}
`).join("")}
${(d.scoring || {}).ranges ? subhead("Interprétation") + d.scoring.ranges.map(r => `${r.min}–${r.max} : ${esc(r.label)}
`).join("") : ""}
Fermer
`;
host.scrollIntoView({ behavior: "smooth", block: "nearest" });
} catch (e) { toast(e.message, "err"); }
}
// --- assignation + résultats par patient ---
async function loadPatientQ(serial, pid) {
const box = $("#qpatbox"); if (!pid) { box.innerHTML = ""; return; }
box.innerHTML = `
`;
try {
const [cat, assigns, results] = await Promise.all([
api(`/questionnaires`), api(`/questionnaires/assignments?serial=${serial}&profile_id=${pid}`), api(`/questionnaires/results/${serial}/${pid}`),
]);
const titleOf = (qid) => (cat.find(x => x.id === qid) || {}).title || qid;
const assignForm = `
${cat.map(q => `${esc(q.title)} `).join("")}
1 fois récurrent
tous les j ·
fois
assigner
`;
const assignList = assigns.length ? assigns.map(a => `
${esc(titleOf(a.questionnaire_id))} · ${a.frequency.mode === "recurring" ? `tous les ${a.frequency.every_days || "?"} j${a.frequency.count ? " × " + a.frequency.count : ""}` : "1 fois"}
retirer
`).join("") : `aucune assignation active
`;
const resList = results.length ? `questionnaire date score interprétation
${results.map(r => `${esc(titleOf(r.questionnaire_id))} ${fmtTs(r.completed_at)}
${r.result.total_score ?? "—"} ${r.result.interpretation ? badge(r.result.interpretation, "primary") : "—"} `).join("")}
` : `aucun résultat enregistré
`;
box.innerHTML = `${subhead("Assigner")}${assignForm}${subhead("Assignations actives")}${assignList}${subhead("Résultats (dossier patient)")}${resList}`;
} catch (e) { box.innerHTML = `${esc(e.message)}
`; }
}
async function assignQ(btn, serial, pid) {
const mode = $("#qa_mode").value;
const frequency = mode === "recurring"
? { mode, every_days: Number($("#qa_days").value || 7), count: Number($("#qa_count").value || 0) }
: { mode: "once" };
const qid = $("#qa_q").value;
await withBusy(btn, async () => { try {
await api(`/questionnaires/${encodeURIComponent(qid)}/assign`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ serial, profile_id: pid, frequency }) });
toast("questionnaire assigné", "ok"); loadPatientQ(serial, pid);
} catch (e) { toast(e.message, "err"); } });
}
async function deactivateQ(btn, aid, serial, pid) {
await withBusy(btn, async () => { try { await api(`/questionnaires/assignments/${aid}/deactivate`, { method: "POST" }); toast("assignation retirée", "ok"); loadPatientQ(serial, pid); } catch (e) { toast(e.message, "err"); } });
}
// ---- store ----------------------------------------------------------------
async function lockStore() { try { await api("/lock", { method: "POST" }); S.unlocked = false; renderGate(); } catch (e) { toast(e.message, "err"); } }