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:
parent
84793cdd76
commit
6a81b17c0a
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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"); } }
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
Loading…
Reference in New Issue