diff --git a/kazeia_central/app.py b/kazeia_central/app.py index 235bbe3..2261512 100644 --- a/kazeia_central/app.py +++ b/kazeia_central/app.py @@ -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: diff --git a/kazeia_central/core/store/db.py b/kazeia_central/core/store/db.py index e7f2c1f..fe466c0 100644 --- a/kazeia_central/core/store/db.py +++ b/kazeia_central/core/store/db.py @@ -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: diff --git a/kazeia_central/features/medical/questionnaires/__init__.py b/kazeia_central/features/medical/questionnaires/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kazeia_central/features/medical/questionnaires/router.py b/kazeia_central/features/medical/questionnaires/router.py new file mode 100644 index 0000000..77b81f6 --- /dev/null +++ b/kazeia_central/features/medical/questionnaires/router.py @@ -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 diff --git a/kazeia_central/features/medical/questionnaires/service.py b/kazeia_central/features/medical/questionnaires/service.py new file mode 100644 index 0000000..84b9794 --- /dev/null +++ b/kazeia_central/features/medical/questionnaires/service.py @@ -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) diff --git a/kazeia_central/web/app.js b/kazeia_central/web/app.js index 6c5496f..6bfb891 100644 --- a/kazeia_central/web/app.js +++ b/kazeia_central/web/app.js @@ -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 = `
${esc(e.message)}
`; 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) ? ` + ${qres.map(r => ` + `).join("")} +
questionnairedatescoreinterprétation
${esc(r.questionnaire_id)}${fmtTs(r.completed_at)}${r.result.total_score ?? "—"}${r.result.interpretation ? badge(r.result.interpretation, "primary") : "—"}
` : `
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
` @@ -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) + `
`, "full")} + ${qresCard}
`; } @@ -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 ? `
${esc(list.error)}
` : + (qs.length ? ` + ${qs.map(q => ` + + `).join("")} +
questionnairequestionstype
${esc(q.title)} ${q.builtin ? badge("fourni", "primary") : ""}
${esc(q.id)}
${q.n_questions}QCM + + ${q.builtin ? "" : ``}
` : `
aucun questionnaire
`) + + `
`, "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.
` + : ` +
`, "full"); + + c.innerHTML = pageHead("Questionnaires cliniques", "Échelles QCM — authoring médecin, assignation par patient, scores dans le dossier") + + `
${catalog}
${assignPanel}
`; + + if (serial) { + const ps = await api(`/patients/${serial}`).catch(() => []); + const sel = $("#qpat"); + if (sel && Array.isArray(ps)) sel.innerHTML = `` + + ps.map(p => ``).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} + ${subhead(`Questions (${QB.questions.length})`)}${qRows} + ${subhead("Interprétation du score total (bandes)")}${rRows} +
+
`; + 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 => `
  1. ${esc(qq.text)} +
    ${(qq.choices || scale).map(ch => `${esc(ch.label)} (${ch.score})`).join(" · ")}
  2. `).join("")}
+ ${(d.scoring || {}).ranges ? subhead("Interprétation") + d.scoring.ranges.map(r => `
${r.min}–${r.max} : ${esc(r.label)}
`).join("") : ""} +
`; + 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 = `
+ + + +
`; + 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"} +
`).join("") : `
aucune assignation active
`; + const resList = results.length ? ` + ${results.map(r => ` + `).join("")} +
questionnairedatescoreinterprétation
${esc(titleOf(r.questionnaire_id))}${fmtTs(r.completed_at)}${r.result.total_score ?? "—"}${r.result.interpretation ? badge(r.result.interpretation, "primary") : "—"}
` : `
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"); } } diff --git a/tests/test_questionnaires.py b/tests/test_questionnaires.py new file mode 100644 index 0000000..6d5d66d --- /dev/null +++ b/tests/test_questionnaires.py @@ -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)