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 = `
| questionnaire | date | score | interprétation |
|---|---|---|---|
| ${esc(r.questionnaire_id)} | ${fmtTs(r.completed_at)} | +${r.result.total_score ?? "—"} | ${r.result.interpretation ? badge(r.result.interpretation, "primary") : "—"} |
| questionnaire | questions | type | |
|---|---|---|---|
| ${esc(q.title)} ${q.builtin ? badge("fourni", "primary") : ""} ${esc(q.id)} |
+ ${q.n_questions} | QCM | ++ + ${q.builtin ? "" : ``} |
${esc(d.description)}
` : ""} +| questionnaire | date | score | interprétation |
|---|---|---|---|
| ${esc(titleOf(r.questionnaire_id))} | ${fmtTs(r.completed_at)} | +${r.result.total_score ?? "—"} | ${r.result.interpretation ? badge(r.result.interpretation, "primary") : "—"} |