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 .core.store import Store
from .features.medical.conversations.router import make_router as conversations_router 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.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.medical.rag.router import make_router as rag_router
from .features.technical.fleet.router import make_router as fleet_router from .features.technical.fleet.router import make_router as fleet_router
from .features.technical.voices.autosync import VoiceAutoSync 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 # 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. # DANS chaque router). Ajouter une feature = ajouter sa fabrique ici.
_FEATURES = (operator_router, fleet_router, voices_router, patients_router, _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: def _install_basic_auth(app: FastAPI) -> None:

View File

@ -103,9 +103,71 @@ CREATE TABLE IF NOT EXISTS patient_fiche (
updated_at INTEGER, updated_at INTEGER,
PRIMARY KEY (serial, profile_id) 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): class StoreLocked(Exception):
"""Opération sur du contenu chiffré alors que le store n'est pas déverrouillé.""" """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.execute("PRAGMA foreign_keys=ON")
self._db.executescript(_SCHEMA) self._db.executescript(_SCHEMA)
self._migrate() self._migrate()
self._seed_builtins()
self._db.commit() self._db.commit()
self._vault: Vault | None = None 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: def _migrate(self) -> None:
"""Migrations légères idempotentes (CREATE TABLE IF NOT EXISTS ne fait pas """Migrations légères idempotentes (CREATE TABLE IF NOT EXISTS ne fait pas
évoluer une table déjà créée).""" é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"]} out[r["profile_id"]] = {"fiche": json.loads(fx) if fx else {}, "language": r["language"]}
return out 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) ------------------------------------------------------- # ---- audit (§8) -------------------------------------------------------
def audit(self, action: str, *, actor: str | None = None, target: str | None = None, def audit(self, action: str, *, actor: str | None = None, target: str | None = None,
detail: dict[str, Any] | None = None, now: int) -> 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: "patients", ic: "👤", label: "Patients & profils", render: renderPatients },
{ id: "conversations", ic: "💬", label: "Conversations", render: renderConversations }, { id: "conversations", ic: "💬", label: "Conversations", render: renderConversations },
{ id: "rag", ic: "📚", label: "RAG thérapeutique", render: renderRag }, { 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 --------------------------------------------------------- // ---- 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; } 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 prof = d.profile || {}, f = d.fiche || {}, civ = f.civil || {}, con = f.contact || {}, cli = f.clinical || {};
const curLang = d.language || prof.language || "fr"; 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"> 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" : "—") ${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>` + `<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("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) + 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")} + `<div style="margin-top:12px"><button class="btn-primary" onclick="saveFiche(this,'${serial}','${pid}')">Enregistrer la fiche</button></div>`, "full")}
${qresCard}
</div>`; </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 ---------------------------------------------------------------- // ---- store ----------------------------------------------------------------
async function lockStore() { try { await api("/lock", { method: "POST" }); S.unlocked = false; renderGate(); } catch (e) { toast(e.message, "err"); } } 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)