feat(questionnaires): échelles QCM cliniques (PHQ-9), assignation par patient, scores au dossier

Feature médicale central-only (passation on-device = future spec RPC).

- store: 3 tables (questionnaires template clair, assignments, results chiffrés
  PII); PHQ-9 semé d'office (builtin non supprimable), scoring sum + bandes.
- backend: features/medical/questionnaires (CRUD authoring, scoring +
  interprétation, assignation fréquence once|recurring, record_result, lecture
  par dossier patient); router câblé dans app.py.
- UI: catalogue niveau flotte + éditeur QCM dynamique; assignation & résultats
  par patient; résultats aussi dans le dossier patient (section Patients).
- tests: +5 (seeding, scoring 0/27, authoring, assign/deactivate, chiffrement
  au repos). 59/59. Vérifié end-to-end via TestClient (gate 423, CRUD, scoring,
  builtin protégé).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kazeia 2026-07-01 21:57:44 +02:00
parent 84793cdd76
commit 6a81b17c0a
7 changed files with 592 additions and 2 deletions

View File

@ -24,6 +24,7 @@ from .core.operator_router import make_router as operator_router
from .core.store import Store
from .features.medical.conversations.router import make_router as conversations_router
from .features.medical.patients.router import make_router as patients_router
from .features.medical.questionnaires.router import make_router as questionnaires_router
from .features.medical.rag.router import make_router as rag_router
from .features.technical.fleet.router import make_router as fleet_router
from .features.technical.voices.autosync import VoiceAutoSync
@ -37,7 +38,7 @@ _OPERATOR = os.environ.get("KAZEIA_USER") or "local"
# Routers de features à composer (ordre libre ; les littéraux vs /{serial} sont gérés
# DANS chaque router). Ajouter une feature = ajouter sa fabrique ici.
_FEATURES = (operator_router, fleet_router, voices_router, patients_router,
conversations_router, rag_router)
conversations_router, rag_router, questionnaires_router)
def _install_basic_auth(app: FastAPI) -> None:

View File

@ -103,9 +103,71 @@ CREATE TABLE IF NOT EXISTS patient_fiche (
updated_at INTEGER,
PRIMARY KEY (serial, profile_id)
);
-- Questionnaires cliniques (créés par les médecins). Définition = template (pas de PII
-- patient) en clair. QCM : questions[{text, type, choices:[{label, score}]}] + scoring.
CREATE TABLE IF NOT EXISTS questionnaires (
id TEXT PRIMARY KEY, -- slug (ex. phq9)
title TEXT,
definition TEXT, -- JSON {description, questions[], scoring}
builtin INTEGER DEFAULT 0, -- 1 = fourni (PHQ-9), non supprimable
updated_at INTEGER
);
-- Assignation d'un questionnaire à un patient + fréquence (once / recurring).
CREATE TABLE IF NOT EXISTS questionnaire_assignments (
id TEXT PRIMARY KEY,
questionnaire_id TEXT NOT NULL,
serial TEXT NOT NULL,
profile_id TEXT NOT NULL,
frequency TEXT, -- JSON {mode: once|recurring, every_days?, count?}
created_at INTEGER,
active INTEGER DEFAULT 1
);
-- Résultats de passation dossier patient. Réponses = PII santé CHIFFRÉES.
CREATE TABLE IF NOT EXISTS questionnaire_results (
id TEXT PRIMARY KEY,
questionnaire_id TEXT NOT NULL,
serial TEXT NOT NULL,
profile_id TEXT NOT NULL,
completed_at INTEGER,
result_enc BLOB -- JSON {answers, total_score, interpretation} chiffré
);
"""
# Échelle PHQ-9 partagée : une question = choix unique sur cette échelle (score 0-3).
_PHQ9_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},
]
_PHQ9 = {
"description": "Au cours des 2 dernières semaines, à quelle fréquence avez-vous été "
"gêné(e) par les problèmes suivants ?",
"type": "qcm",
"scale": _PHQ9_SCALE, # échelle par défaut (une question sans `choices` l'hérite)
"questions": [
{"text": "Peu d'intérêt ou de plaisir à faire les choses"},
{"text": "Se sentir triste, déprimé(e) ou désespéré(e)"},
{"text": "Difficultés à s'endormir, à rester endormi(e), ou dormir trop"},
{"text": "Se sentir fatigué(e) ou avoir peu d'énergie"},
{"text": "Manque d'appétit ou manger trop"},
{"text": "Mauvaise perception de soi — se sentir raté(e) ou avoir déçu ses proches"},
{"text": "Difficultés à se concentrer (lecture, télévision…)"},
{"text": "Lenteur ou agitation inhabituelle remarquée par les autres"},
{"text": "Penser qu'il vaudrait mieux être mort(e) ou vouloir se faire du mal"},
],
"scoring": {"method": "sum", "ranges": [
{"min": 0, "max": 4, "label": "dépression minimale"},
{"min": 5, "max": 9, "label": "dépression légère"},
{"min": 10, "max": 14, "label": "dépression modérée"},
{"min": 15, "max": 19, "label": "dépression modérément sévère"},
{"min": 20, "max": 27, "label": "dépression sévère"},
]},
}
_BUILTIN_QUESTIONNAIRES = [("phq9", "PHQ-9 (dépression)", _PHQ9)]
class StoreLocked(Exception):
"""Opération sur du contenu chiffré alors que le store n'est pas déverrouillé."""
@ -126,9 +188,18 @@ class Store:
self._db.execute("PRAGMA foreign_keys=ON")
self._db.executescript(_SCHEMA)
self._migrate()
self._seed_builtins()
self._db.commit()
self._vault: Vault | None = None
def _seed_builtins(self) -> None:
"""Insère les questionnaires fournis (PHQ-9) s'ils n'existent pas. Définitions =
templates (pas de PII) pas besoin du vault."""
for qid, title, definition in _BUILTIN_QUESTIONNAIRES:
self._db.execute(
"INSERT OR IGNORE INTO questionnaires(id, title, definition, builtin, updated_at) "
"VALUES(?,?,?,1,0)", (qid, title, json.dumps(definition, ensure_ascii=False)))
def _migrate(self) -> None:
"""Migrations légères idempotentes (CREATE TABLE IF NOT EXISTS ne fait pas
évoluer une table déjà créée)."""
@ -540,6 +611,101 @@ class Store:
out[r["profile_id"]] = {"fiche": json.loads(fx) if fx else {}, "language": r["language"]}
return out
# ---- questionnaires cliniques (médical) -------------------------------
def list_questionnaires(self) -> list[dict[str, Any]]:
with self._lock:
rows = self._db.execute(
"SELECT id, title, builtin, updated_at, definition FROM questionnaires "
"ORDER BY builtin DESC, title").fetchall()
out = []
for r in rows:
d = json.loads(r["definition"] or "{}")
out.append({"id": r["id"], "title": r["title"], "builtin": bool(r["builtin"]),
"updated_at": r["updated_at"], "n_questions": len(d.get("questions", []))})
return out
def get_questionnaire(self, qid: str) -> dict[str, Any] | None:
with self._lock:
r = self._db.execute("SELECT * FROM questionnaires WHERE id=?", (qid,)).fetchone()
if not r:
return None
return {"id": r["id"], "title": r["title"], "builtin": bool(r["builtin"]),
"definition": json.loads(r["definition"] or "{}"), "updated_at": r["updated_at"]}
def upsert_questionnaire(self, qid: str, title: str, definition: dict, *, now: int) -> None:
with self._lock:
self._db.execute(
"INSERT INTO questionnaires(id, title, definition, builtin, updated_at) VALUES(?,?,?,0,?) "
"ON CONFLICT(id) DO UPDATE SET title=excluded.title, definition=excluded.definition, "
"updated_at=excluded.updated_at", (qid, title, json.dumps(definition, ensure_ascii=False), now))
self._db.commit()
def delete_questionnaire(self, qid: str) -> bool:
with self._lock:
r = self._db.execute("SELECT builtin FROM questionnaires WHERE id=?", (qid,)).fetchone()
if not r or r["builtin"]:
return False # fourni (PHQ-9) → non supprimable
self._db.execute("DELETE FROM questionnaires WHERE id=?", (qid,))
self._db.commit()
return True
def _gen_id(self, prefix: str, *parts: Any) -> str:
return prefix + "_" + hashlib.sha256("|".join(str(p) for p in parts).encode()).hexdigest()[:12]
def assign_questionnaire(self, qid: str, serial: str, profile_id: str,
frequency: dict, *, now: int) -> str:
aid = self._gen_id("as", qid, serial, profile_id, now)
with self._lock:
self._db.execute(
"INSERT OR REPLACE INTO questionnaire_assignments(id, questionnaire_id, serial, "
"profile_id, frequency, created_at, active) VALUES(?,?,?,?,?,?,1)",
(aid, qid, serial, profile_id, json.dumps(frequency), now))
self._db.commit()
return aid
def assignments(self, *, serial: str | None = None, profile_id: str | None = None,
active_only: bool = True) -> list[dict[str, Any]]:
q = "SELECT * FROM questionnaire_assignments WHERE 1=1"
a: list = []
if serial:
q += " AND serial=?"; a.append(serial)
if profile_id:
q += " AND profile_id=?"; a.append(profile_id)
if active_only:
q += " AND active=1"
with self._lock:
rows = self._db.execute(q + " ORDER BY created_at DESC", a).fetchall()
return [{**dict(r), "frequency": json.loads(r["frequency"] or "{}")} for r in rows]
def deactivate_assignment(self, aid: str) -> None:
with self._lock:
self._db.execute("UPDATE questionnaire_assignments SET active=0 WHERE id=?", (aid,))
self._db.commit()
def record_result(self, qid: str, serial: str, profile_id: str, result: dict, *, now: int) -> str:
v = self._require_vault()
rid = self._gen_id("qr", qid, serial, profile_id, now)
with self._lock:
self._db.execute(
"INSERT OR REPLACE INTO questionnaire_results(id, questionnaire_id, serial, "
"profile_id, completed_at, result_enc) VALUES(?,?,?,?,?,?)",
(rid, qid, serial, profile_id, now, v.seal(json.dumps(result))))
self._db.commit()
return rid
def patient_results(self, serial: str, profile_id: str) -> list[dict[str, Any]]:
v = self._require_vault()
with self._lock:
rows = self._db.execute(
"SELECT * FROM questionnaire_results WHERE serial=? AND profile_id=? "
"ORDER BY completed_at DESC", (serial, profile_id)).fetchall()
out = []
for r in rows:
res = v.open(r["result_enc"])
out.append({"id": r["id"], "questionnaire_id": r["questionnaire_id"],
"completed_at": r["completed_at"], "result": json.loads(res) if res else {}})
return out
# ---- audit (§8) -------------------------------------------------------
def audit(self, action: str, *, actor: str | None = None, target: str | None = None,
detail: dict[str, Any] | None = None, now: int) -> None:

View File

@ -0,0 +1,82 @@
"""Router Questionnaires cliniques (médical) : authoring + assignation + résultats.
Feature **central-only** (store chiffré) : pas d'adb ici. La passation on-device
et sa remontée arriveront via une future spec RPC ; `record_result` sert dès
maintenant de point d'entrée (saisie assistée / import futur).
"""
from __future__ import annotations
from typing import Any
from fastapi import APIRouter
from pydantic import BaseModel
from kazeia_central.core.context import Ctx
from . import service as svc
class _Questionnaire(BaseModel):
title: str
definition: dict[str, Any]
class _Assign(BaseModel):
serial: str
profile_id: str
frequency: dict[str, Any] # {mode: "once"|"recurring", every_days?, count?}
class _Result(BaseModel):
serial: str
profile_id: str
answers: list[int | None] # index du choix retenu par question
def make_router(ctx: Ctx) -> APIRouter:
r = APIRouter(prefix="/api/questionnaires", tags=["questionnaires"])
g, now = ctx.guard, ctx.now_ms
# --- littéraux AVANT /{qid} (FastAPI matche dans l'ordre de déclaration) ---
@r.get("/assignments")
def list_assignments(serial: str | None = None, profile_id: str | None = None):
return g(lambda: svc.assignments(ctx.store, serial=serial, profile_id=profile_id))
@r.post("/assignments/{aid}/deactivate")
def deactivate_assignment(aid: str):
return g(lambda: svc.deactivate(ctx.store, aid, now=now(), actor=ctx.operator))
@r.get("/results/{serial}/{profile_id}")
def patient_results(serial: str, profile_id: str):
return g(lambda: svc.patient_results(ctx.store, serial, profile_id))
# --- catalogue de questionnaires ---
@r.get("")
def list_questionnaires():
return g(lambda: svc.list_questionnaires(ctx.store))
@r.get("/{qid}")
def get_questionnaire(qid: str):
return g(lambda: svc.get_questionnaire(ctx.store, qid))
@r.put("/{qid}")
def save_questionnaire(qid: str, body: _Questionnaire):
return g(lambda: svc.save_questionnaire(ctx.store, qid, body.title, body.definition,
now=now(), actor=ctx.operator))
@r.delete("/{qid}")
def delete_questionnaire(qid: str):
return g(lambda: svc.delete_questionnaire(ctx.store, qid, now=now(), actor=ctx.operator))
# --- assignation + passation ---
@r.post("/{qid}/assign")
def assign(qid: str, body: _Assign):
return g(lambda: svc.assign(ctx.store, qid, body.serial, body.profile_id, body.frequency,
now=now(), actor=ctx.operator))
@r.post("/{qid}/result")
def record_result(qid: str, body: _Result):
return g(lambda: svc.record_result(ctx.store, qid, body.serial, body.profile_id,
body.answers, now=now(), actor=ctx.operator))
return r

View File

@ -0,0 +1,90 @@
"""Service Questionnaires cliniques (médical).
Périmètre CENTRAL (la passation on-device viendra plus tard, via une future spec RPC) :
- **authoring** par les médecins (QCM : questions choix scorés) ; PHQ-9 fourni ;
- **assignation** à un/des patients avec une **fréquence** (1 fois / récurrent) ;
- **résultats** de passation **dossier patient** (réponses chiffrées, score calculé).
"""
from __future__ import annotations
from typing import Any
from kazeia_central.core.store import Store
def list_questionnaires(store: Store) -> list[dict[str, Any]]:
return store.list_questionnaires()
def get_questionnaire(store: Store, qid: str) -> dict[str, Any] | None:
return store.get_questionnaire(qid)
def save_questionnaire(store: Store, qid: str, title: str, definition: dict, *,
now: int, actor: str | None = None) -> dict[str, Any]:
store.upsert_questionnaire(qid, title, definition, now=now)
store.audit("questionnaire_saved", actor=actor, target=qid,
detail={"questions": len(definition.get("questions", []))}, now=now)
return {"id": qid, "title": title}
def delete_questionnaire(store: Store, qid: str, *, now: int, actor: str | None = None) -> dict[str, Any]:
ok = store.delete_questionnaire(qid)
if ok:
store.audit("questionnaire_deleted", actor=actor, target=qid, now=now)
return {"deleted": ok}
def assign(store: Store, qid: str, serial: str, profile_id: str, frequency: dict, *,
now: int, actor: str | None = None) -> dict[str, Any]:
aid = store.assign_questionnaire(qid, serial, profile_id, frequency, now=now)
store.audit("questionnaire_assigned", actor=actor, target=f"{serial}/{profile_id}",
detail={"questionnaire": qid, "frequency": frequency}, now=now)
return {"assignment_id": aid, "questionnaire_id": qid}
def assignments(store: Store, *, serial: str | None = None, profile_id: str | None = None) -> list[dict[str, Any]]:
return store.assignments(serial=serial, profile_id=profile_id)
def deactivate(store: Store, aid: str, *, now: int, actor: str | None = None) -> dict[str, Any]:
store.deactivate_assignment(aid)
store.audit("questionnaire_unassigned", actor=actor, target=aid, now=now)
return {"assignment_id": aid, "active": False}
def score(definition: dict, answers: list[int | None]) -> dict[str, Any]:
"""Calcule le score (somme des choix sélectionnés) + l'interprétation (ranges)."""
scale = definition.get("scale") or []
qs = definition.get("questions") or []
total = 0
for i, q in enumerate(qs):
choices = q.get("choices") or scale
a = answers[i] if i < len(answers) else None
if a is not None and 0 <= a < len(choices):
total += int(choices[a].get("score", 0))
label = None
for rng in (definition.get("scoring") or {}).get("ranges", []):
if rng["min"] <= total <= rng["max"]:
label = rng["label"]
break
return {"total_score": total, "interpretation": label}
def record_result(store: Store, qid: str, serial: str, profile_id: str, answers: list, *,
now: int, actor: str | None = None) -> dict[str, Any]:
"""Enregistre une passation (réponses chiffrées + score) dans le dossier patient."""
q = store.get_questionnaire(qid)
if not q:
raise ValueError(f"questionnaire inconnu: {qid}")
scored = score(q["definition"], answers)
result = {"answers": answers, **scored}
rid = store.record_result(qid, serial, profile_id, result, now=now)
store.audit("questionnaire_result", actor=actor, target=f"{serial}/{profile_id}",
detail={"questionnaire": qid, "score": scored["total_score"]}, now=now)
return {"result_id": rid, **scored}
def patient_results(store: Store, serial: str, profile_id: str) -> list[dict[str, Any]]:
return store.patient_results(serial, profile_id)

View File

@ -35,7 +35,7 @@ const SECTIONS = [
{ 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: () => stub("Questionnaires cliniques", "Passation d'échelles/questionnaires cliniques au patient et suivi des scores dans le temps. À concevoir avec toi.") },
{ id: "questionnaires", ic: "📝", label: "Questionnaires", render: renderQuestionnaires },
];
// ---- boot + shell ---------------------------------------------------------
@ -248,6 +248,12 @@ async function openPatient(serial, pid) {
let d; try { d = await api(`/patients/${serial}/${pid}`); } catch (e) { host.innerHTML = `<div class="card"><div class="err">${esc(e.message)}</div></div>`; 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 <span class='badge'>chiffrés · PC</span>",
(Array.isArray(qres) && qres.length) ? `<table><tr><th>questionnaire</th><th>date</th><th>score</th><th>interprétation</th></tr>
${qres.map(r => `<tr><td>${esc(r.questionnaire_id)}</td><td class="muted">${fmtTs(r.completed_at)}</td>
<td><b>${r.result.total_score ?? "—"}</b></td><td>${r.result.interpretation ? badge(r.result.interpretation, "primary") : ""}</td></tr>`).join("")}
</table>` : `<div class="muted">aucun résultat assigne/saisis un questionnaire dans la section Questionnaires</div>`, "full");
host.innerHTML = `<div class="page-head" style="margin-top:22px"><h1 style="font-size:16px">Patient · ${esc(prof.display_name || pid)}</h1></div><div class="grid">
${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" : "—")
+ `<div class="kv"><span class="k">langue du pipeline</span><span class="v"><select id="lang">${LANGS.map(l => `<option ${l === curLang ? "selected" : ""}>${l}</option>`).join("")}</select> <button class="btn-sm btn-primary" onclick="pushLanguage(this,'${serial}','${pid}')">pousser</button></span></div>`
@ -257,6 +263,7 @@ async function openPatient(serial, pid) {
+ 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)
+ `<div style="margin-top:12px"><button class="btn-primary" onclick="saveFiche(this,'${serial}','${pid}')">Enregistrer la fiche</button></div>`, "full")}
${qresCard}
</div>`;
}
@ -380,5 +387,179 @@ async function ragQuery(btn, serial) {
});
}
// ---- 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 ? `<div class="err">${esc(list.error)}</div>` :
(qs.length ? `<table><tr><th>questionnaire</th><th>questions</th><th>type</th><th></th></tr>
${qs.map(q => `<tr><td><b>${esc(q.title)}</b> ${q.builtin ? badge("fourni", "primary") : ""}<div class="muted mono">${esc(q.id)}</div></td>
<td>${q.n_questions}</td><td>QCM</td>
<td style="white-space:nowrap"><button class="btn-sm" onclick="previewQuestionnaire('${esc(q.id)}')">aperçu</button>
<button class="btn-sm" onclick="editQuestionnaire('${esc(q.id)}')">${q.builtin ? "dupliquer" : "éditer"}</button>
${q.builtin ? "" : `<button class="btn-sm" onclick="delQuestionnaire(this,'${esc(q.id)}')">suppr.</button>`}</td></tr>`).join("")}
</table>` : `<div class="empty">aucun questionnaire</div>`) +
`<div style="margin-top:12px"><button class="btn-primary" onclick="newQuestionnaire()">+ Nouveau questionnaire (QCM)</button></div>`, "full");
const assignPanel = card("Assignation & résultats · par patient",
!serial ? `<div class="empty">Choisis une tablette (en haut) puis un patient pour assigner un questionnaire et consulter ses résultats.</div>`
: `<label style="display:flex;align-items:center;gap:10px"><span style="color:var(--muted)">Patient</span>
<select id="qpat" onchange="loadPatientQ('${serial}', this.value)" style="flex:1"><option value=""> choisir </option></select></label>
<div id="qpatbox" style="margin-top:14px"></div>`, "full");
c.innerHTML = pageHead("Questionnaires cliniques", "Échelles QCM — authoring médecin, assignation par patient, scores dans le dossier")
+ `<div class="grid">${catalog}<div id="qedit"></div>${assignPanel}</div>`;
if (serial) {
const ps = await api(`/patients/${serial}`).catch(() => []);
const sel = $("#qpat");
if (sel && Array.isArray(ps)) sel.innerHTML = `<option value="">— choisir —</option>` +
ps.map(p => `<option value="${esc(p.profile_id)}">${esc(p.full_name || p.display_name || p.profile_id)}</option>`).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) => `<div style="display:flex;gap:6px;margin:4px 0;align-items:center">
<input id="qb_sl_${i}" value="${esc(s.label)}" placeholder="libellé du choix" style="flex:1">
<input id="qb_ss_${i}" type="number" value="${s.score}" title="score" style="width:70px">
<button class="btn-sm btn-ghost" onclick="qbDelScale(${i})">×</button></div>`).join("");
const qRows = QB.questions.map((q, i) => `<div style="display:flex;gap:6px;margin:4px 0;align-items:center">
<span class="muted mono" style="width:24px">${i + 1}</span>
<input id="qb_q_${i}" value="${esc(q.text)}" placeholder="énoncé de la question" style="flex:1">
<button class="btn-sm btn-ghost" onclick="qbDelQuestion(${i})">×</button></div>`).join("");
const rRows = QB.ranges.map((r, i) => `<div style="display:flex;gap:6px;margin:4px 0;align-items:center">
<input id="qb_rmin_${i}" type="number" value="${r.min}" title="min" style="width:70px">
<span class="muted"></span><input id="qb_rmax_${i}" type="number" value="${r.max}" title="max" style="width:70px">
<input id="qb_rl_${i}" value="${esc(r.label)}" placeholder="interprétation (ex. dépression modérée)" style="flex:1">
<button class="btn-sm btn-ghost" onclick="qbDelRange(${i})">×</button></div>`).join("");
const maxScore = QB.questions.length * Math.max(0, ...QB.scale.map(s => s.score || 0));
host.innerHTML = `<h3>${QB.id ? "Éditer" : "Nouveau"} questionnaire QCM</h3>
${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}`)}
<div class="muted" style="font-size:12px;margin-bottom:4px">Chaque question propose ces choix (QCM à choix unique, chaque choix vaut un score).</div>
${scaleRows}<button class="btn-sm" onclick="qbAddScale()">+ choix</button>
${subhead(`Questions (${QB.questions.length})`)}${qRows}<button class="btn-sm" onclick="qbAddQuestion()">+ question</button>
${subhead("Interprétation du score total (bandes)")}${rRows}<button class="btn-sm" onclick="qbAddRange()">+ bande</button>
<div style="margin-top:14px;display:flex;gap:8px"><button class="btn-primary" onclick="saveQuestionnaire(this)">Enregistrer</button>
<button class="btn-ghost" onclick="qbCancel()">Annuler</button></div>`;
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 = `<h3>${esc(q.title)} <span class="badge">aperçu</span></h3>
${d.description ? `<p class="muted">${esc(d.description)}</p>` : ""}
<ol style="padding-left:20px">${(d.questions || []).map(qq => `<li style="margin:6px 0">${esc(qq.text)}
<div class="muted" style="font-size:12px">${(qq.choices || scale).map(ch => `${esc(ch.label)} (${ch.score})`).join(" · ")}</div></li>`).join("")}</ol>
${(d.scoring || {}).ranges ? subhead("Interprétation") + d.scoring.ranges.map(r => `<div class="muted" style="font-size:13px">${r.min}${r.max} : <b>${esc(r.label)}</b></div>`).join("") : ""}
<div style="margin-top:12px"><button class="btn-ghost" onclick="qbCancel()">Fermer</button></div>`;
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 = `<div class="empty"><span class="spinner"></span></div>`;
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 = `<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin:6px 0">
<select id="qa_q">${cat.map(q => `<option value="${esc(q.id)}">${esc(q.title)}</option>`).join("")}</select>
<select id="qa_mode" onchange="$('#qa_rec').style.display=this.value==='recurring'?'inline-flex':'none'">
<option value="once">1 fois</option><option value="recurring">récurrent</option></select>
<span id="qa_rec" style="display:none;gap:6px;align-items:center">tous les <input id="qa_days" type="number" value="7" style="width:60px"> j ·
<input id="qa_count" type="number" value="4" style="width:60px" title="nombre d'occurrences (0 = illimité)"> fois</span>
<button class="btn-sm btn-primary" onclick="assignQ(this,'${serial}','${pid}')">assigner</button></div>`;
const assignList = assigns.length ? assigns.map(a => `<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border:1px solid var(--line);border-radius:8px;margin:4px 0;background:var(--surface-2)">
<span><b>${esc(titleOf(a.questionnaire_id))}</b> · ${a.frequency.mode === "recurring" ? `tous les ${a.frequency.every_days || "?"} j${a.frequency.count ? " × " + a.frequency.count : ""}` : "1 fois"}</span>
<button class="btn-sm btn-ghost" onclick="deactivateQ(this,'${esc(a.id)}','${serial}','${pid}')">retirer</button></div>`).join("") : `<div class="muted">aucune assignation active</div>`;
const resList = results.length ? `<table><tr><th>questionnaire</th><th>date</th><th>score</th><th>interprétation</th></tr>
${results.map(r => `<tr><td>${esc(titleOf(r.questionnaire_id))}</td><td class="muted">${fmtTs(r.completed_at)}</td>
<td><b>${r.result.total_score ?? "—"}</b></td><td>${r.result.interpretation ? badge(r.result.interpretation, "primary") : ""}</td></tr>`).join("")}
</table>` : `<div class="muted">aucun résultat enregistré</div>`;
box.innerHTML = `${subhead("Assigner")}${assignForm}${subhead("Assignations actives")}${assignList}${subhead("Résultats (dossier patient)")}${resList}`;
} catch (e) { box.innerHTML = `<div class="err">${esc(e.message)}</div>`; }
}
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"); } }

View File

@ -0,0 +1,70 @@
"""Tests feature Questionnaires : PHQ-9 fourni, authoring QCM, scoring, assignation,
résultats chiffrés dans le dossier patient."""
import nacl.pwhash
from kazeia_central.core.store import Store
from kazeia_central.features.medical.questionnaires import service as svc
_OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN
_MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN
NOW = 1_700_000_000_000
def _store(tmp_path):
s = Store(tmp_path / "c.db", kdf_ops=_OPS, kdf_mem=_MEM)
s.unlock("pw")
return s
def test_phq9_seeded_and_undeletable(tmp_path):
s = _store(tmp_path)
ids = {q["id"] for q in svc.list_questionnaires(s)}
assert "phq9" in ids
q = svc.get_questionnaire(s, "phq9")
assert q["builtin"] is True and len(q["definition"]["questions"]) == 9
assert svc.delete_questionnaire(s, "phq9", now=NOW)["deleted"] is False # fourni → protégé
def test_phq9_scoring_bands(tmp_path):
s = _store(tmp_path)
defn = svc.get_questionnaire(s, "phq9")["definition"]
assert svc.score(defn, [0] * 9) == {"total_score": 0, "interpretation": defn["scoring"]["ranges"][0]["label"]}
top = svc.score(defn, [3] * 9)
assert top["total_score"] == 27 and "sévère" in top["interpretation"].lower()
def test_authoring_custom_qcm(tmp_path):
s = _store(tmp_path)
defn = {
"type": "qcm",
"questions": [{"text": "Sommeil ?", "choices": [{"label": "Bon", "score": 0},
{"label": "Mauvais", "score": 2}]}],
"scoring": {"method": "sum", "ranges": [{"min": 0, "max": 1, "label": "ok"},
{"min": 2, "max": 2, "label": "alerte"}]},
}
svc.save_questionnaire(s, "sommeil", "Suivi sommeil", defn, now=NOW, actor="dr")
assert svc.score(defn, [1])["interpretation"] == "alerte"
assert svc.delete_questionnaire(s, "sommeil", now=NOW)["deleted"] is True
def test_assign_list_deactivate(tmp_path):
s = _store(tmp_path)
r = svc.assign(s, "phq9", "SER1", "p_1", {"mode": "recurring", "every_days": 7}, now=NOW, actor="dr")
aid = r["assignment_id"]
ags = svc.assignments(s, serial="SER1")
assert len(ags) == 1 and ags[0]["frequency"]["every_days"] == 7 and ags[0]["active"] == 1
svc.deactivate(s, aid, now=NOW, actor="dr")
assert svc.assignments(s, serial="SER1") == [] # active_only → filtré
def test_result_into_patient_dossier_encrypted(tmp_path):
s = _store(tmp_path)
out = svc.record_result(s, "phq9", "SER1", "p_1", [2, 2, 1, 1, 0, 0, 0, 0, 0], now=NOW, actor="dr")
assert out["total_score"] == 6 and out["interpretation"]
res = svc.patient_results(s, "SER1", "p_1")
assert len(res) == 1 and res[0]["result"]["total_score"] == 6
assert res[0]["result"]["answers"][0] == 2
# PII chiffrée au repos : le blob brut ne contient pas l'interprétation en clair
raw = s._db.execute("SELECT result_enc FROM questionnaire_results").fetchone()["result_enc"]
assert isinstance(raw, (bytes, memoryview)) and b"interpretation" not in bytes(raw)