"""Store local chiffré de Kazeia-central. `sqlite3` (stdlib, zéro install) + chiffrement au niveau champ (`crypto.Vault`, PyNaCl). Contient l'archive clinique rapatriée (tours de conversation, PII de santé chiffrée), le mapping `serial → label patient` (§9), et la piste d'audit (§8). Posture : - Texte des tours et labels patients → **chiffrés** (colonnes `*_enc BLOB`). - Métadonnées (serial, profile_id, session_id, timestamps) → **clair**, pour rester requêtables (dashboard, collecte incrémentale, fan-out). Protégées au repos par le chiffrement disque OS (§8). - Tout accès sensible est journalisé (`audit`). Le store s'ouvre verrouillé ; `unlock(password)` dérive la clé et la garde en mémoire pour la session. Sans unlock, seules les métadonnées sont lisibles, jamais le contenu. Thread-safety : les endpoints FastAPI synchrones tournent dans un threadpool → connexion ouverte avec `check_same_thread=False` et accès sérialisé par un `RLock`. """ from __future__ import annotations import hashlib import json import sqlite3 import threading from pathlib import Path from typing import Any, Iterable from .crypto import KdfParams, Vault _SCHEMA = """ CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value BLOB ); CREATE TABLE IF NOT EXISTS devices ( serial TEXT PRIMARY KEY, label_enc BLOB, -- label patient, chiffré created_at INTEGER NOT NULL, last_seen_at INTEGER ); CREATE TABLE IF NOT EXISTS turns ( serial TEXT NOT NULL, profile_id TEXT, session_id TEXT NOT NULL, turn_id TEXT NOT NULL, -- `id` du tour côté provider ts INTEGER, -- timestamp du tour (clair → tri/incrémental) role TEXT, -- PATIENT | KAZEIA text_enc BLOB, -- contenu, chiffré (PII) ttft_ms INTEGER, total_ms INTEGER, voice_used TEXT, model_used TEXT, archived_at INTEGER NOT NULL, PRIMARY KEY (serial, session_id, turn_id) -- dédoublonne les re-pulls ); CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(serial, session_id, ts); CREATE INDEX IF NOT EXISTS idx_turns_profile ON turns(serial, profile_id, ts); CREATE TABLE IF NOT EXISTS audit ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, actor TEXT, action TEXT NOT NULL, target TEXT, detail TEXT -- métadonnée d'audit (pas de PII) → clair ); -- Catalogue voix GLOBAL (flotte) : une voix = un asset central, PAS lié à une tablette. -- La tablette d'enregistrement n'est qu'une métadonnée (origin_serial) ; le déploiement -- est une relation séparée (voice_deployments). CREATE TABLE IF NOT EXISTS voices ( voice_id TEXT PRIMARY KEY, origin_serial TEXT, -- tablette d'enregistrement (métadonnée) name TEXT, transcription_enc BLOB, -- texte du .ovsp (ASR) → chiffré source_wav_path TEXT, -- chemin device d'origine du WAV wav_file TEXT, -- WAV chiffré archivé (relatif au dossier voices/) wav_sha256 TEXT, ovsp_enc BLOB, -- artefact .ovsp chiffré → déployable sans ré-enrôler ovsp_bytes INTEGER, archived_at INTEGER, -- WAV récupéré + archivé sur le PC enrolled_at INTEGER, -- .ovsp produit wav_deleted_at INTEGER, -- WAV supprimé de la tablette d'origine locked_profile_id TEXT, -- exclusivité (= owner_profile_id ; NULL = généraliste) scope TEXT, -- exclusive | global | pending owner_name TEXT, consent_text_enc BLOB -- phrase de consentement (PII) → chiffré ); -- Déploiement many-to-many : quelles tablettes ont le .ovsp de quelle voix. CREATE TABLE IF NOT EXISTS voice_deployments ( voice_id TEXT NOT NULL, serial TEXT NOT NULL, deployed_at INTEGER, PRIMARY KEY (voice_id, serial) ); -- Dossier patient (médical) : identité/contact/clinique CHIFFRÉS (PII santé, restent -- sur le PC, jamais poussés sur la tablette). `language` = miroir de l'opérationnel poussé. CREATE TABLE IF NOT EXISTS patient_fiche ( serial TEXT NOT NULL, profile_id TEXT NOT NULL, fiche_enc BLOB, -- JSON {civil, contact, clinical} chiffré language TEXT, -- langue opérationnelle (miroir du profil on-device) 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é.""" class Store: def __init__(self, path: str | Path, *, kdf_ops: int | None = None, kdf_mem: int | None = None) -> None: # kdf_ops/kdf_mem : coût Argon2id à l'initialisation (1ᵉʳ unlock). None = # défaut prod MODERATE. Les tests les baissent pour la vitesse ; ils sont # persistés, donc les ré-ouvertures réutilisent les mêmes. self.path = str(path) self._kdf_ops = kdf_ops self._kdf_mem = kdf_mem self._lock = threading.RLock() self._db = sqlite3.connect(self.path, check_same_thread=False) self._db.row_factory = sqlite3.Row self._db.execute("PRAGMA journal_mode=WAL") 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).""" cols = {r["name"] for r in self._db.execute("PRAGMA table_info(voices)")} if "serial" in cols: # ANCIEN schéma per-tablette → mises à niveau puis refonte if "locked_profile_id" not in cols: self._db.execute("ALTER TABLE voices ADD COLUMN locked_profile_id TEXT") if "cvps_bytes" in cols and "ovsp_bytes" not in cols: self._db.execute("ALTER TABLE voices RENAME COLUMN cvps_bytes TO ovsp_bytes") for col, decl in (("scope", "TEXT"), ("owner_name", "TEXT"), ("consent_text_enc", "BLOB")): if col not in cols: self._db.execute(f"ALTER TABLE voices ADD COLUMN {col} {decl}") self._rebuild_voices_global() def _rebuild_voices_global(self) -> None: """Refonte (serial, voice_id) → catalogue GLOBAL clé voice_id + table voice_deployments. Les fichiers WAV chiffrés gardent leur chemin relatif (`/.wav.enc`) → pas de déplacement de fichier.""" db = self._db db.execute("ALTER TABLE voices RENAME TO voices_old") db.executescript(_SCHEMA) # recrée voices (nouveau) + voice_deployments db.execute( "INSERT OR IGNORE INTO voice_deployments(voice_id, serial, deployed_at) " "SELECT voice_id, serial, deployed_at FROM voices_old WHERE deployed_at IS NOT NULL") db.execute( "INSERT OR IGNORE INTO voices(voice_id, origin_serial, transcription_enc, " "source_wav_path, wav_file, wav_sha256, ovsp_bytes, archived_at, enrolled_at, " "wav_deleted_at, locked_profile_id, scope, owner_name, consent_text_enc) " "SELECT voice_id, serial, transcription_enc, source_wav_path, wav_file, " "wav_sha256, ovsp_bytes, archived_at, enrolled_at, wav_deleted_at, " "locked_profile_id, scope, owner_name, consent_text_enc FROM voices_old") db.execute("DROP TABLE voices_old") def close(self) -> None: with self._lock: self._db.close() def __enter__(self) -> "Store": return self def __exit__(self, *exc: object) -> None: self.close() # ---- meta ------------------------------------------------------------- def _get_meta(self, key: str) -> bytes | None: row = self._db.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone() return row["value"] if row else None def _set_meta(self, key: str, value: bytes) -> None: self._db.execute( "INSERT INTO meta(key, value) VALUES(?, ?) " "ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, value), ) self._db.commit() @property def is_initialized(self) -> bool: with self._lock: return self._get_meta("kdf_salt") is not None @property def is_unlocked(self) -> bool: return self._vault is not None # ---- déverrouillage --------------------------------------------------- def unlock(self, password: str) -> None: """Initialise le coffre au 1ᵉʳ appel, sinon valide le mot de passe. Lève `crypto.BadPassword` si incorrect.""" with self._lock: if not self._get_meta("kdf_salt"): from .crypto import _DEFAULT_MEM, _DEFAULT_OPS vault, params, verifier = Vault.setup( password, ops=self._kdf_ops if self._kdf_ops is not None else _DEFAULT_OPS, mem=self._kdf_mem if self._kdf_mem is not None else _DEFAULT_MEM, ) self._set_meta("kdf_salt", params.salt) self._set_meta("kdf_ops", str(params.ops).encode()) self._set_meta("kdf_mem", str(params.mem).encode()) self._set_meta("verifier", verifier) self._vault = vault return params = KdfParams( salt=self._get_meta("kdf_salt"), ops=int(self._get_meta("kdf_ops")), mem=int(self._get_meta("kdf_mem")), ) self._vault = Vault.unlock(password, params, self._get_meta("verifier")) def lock(self) -> None: """Oublie la clé en mémoire (re-verrouille sans fermer la base).""" self._vault = None def _require_vault(self) -> Vault: if self._vault is None: raise StoreLocked("store verrouillé : appeler unlock(password) d'abord") return self._vault # ---- mapping serial → label patient (§9) ------------------------------ def set_device_label(self, serial: str, label: str, *, now: int) -> None: with self._lock: v = self._require_vault() self._db.execute( "INSERT INTO devices(serial, label_enc, created_at, last_seen_at) " "VALUES(?,?,?,?) ON CONFLICT(serial) DO UPDATE SET " "label_enc=excluded.label_enc, last_seen_at=excluded.last_seen_at", (serial, v.seal(label), now, now), ) self._db.commit() def device_label(self, serial: str) -> str | None: with self._lock: v = self._require_vault() row = self._db.execute( "SELECT label_enc FROM devices WHERE serial=?", (serial,) ).fetchone() return v.open(row["label_enc"]) if row else None def device_labels(self) -> dict[str, str | None]: """Tous les labels {serial: label} — pour enrichir la liste du parc.""" with self._lock: v = self._require_vault() return {r["serial"]: v.open(r["label_enc"]) for r in self._db.execute("SELECT serial, label_enc FROM devices")} def devices(self) -> list[dict[str, Any]]: with self._lock: v = self._require_vault() return [{ "serial": r["serial"], "label": v.open(r["label_enc"]), "created_at": r["created_at"], "last_seen_at": r["last_seen_at"], } for r in self._db.execute("SELECT * FROM devices ORDER BY created_at")] # ---- archive des tours (collecte) ------------------------------------- def archive_turns(self, serial: str, turns: Iterable[dict[str, Any]], *, now: int) -> int: """Insère/ignore des tours (idempotent par (serial, session_id, turn_id)). Chaque `text` est chiffré. Renvoie le nombre de NOUVEAUX tours archivés.""" with self._lock: v = self._require_vault() before = self._db.total_changes self._db.executemany( "INSERT OR IGNORE INTO turns(serial, profile_id, session_id, turn_id, ts, " "role, text_enc, ttft_ms, total_ms, voice_used, model_used, archived_at) " "VALUES(:serial,:profile_id,:session_id,:turn_id,:ts,:role,:text_enc," ":ttft_ms,:total_ms,:voice_used,:model_used,:archived_at)", [{ "serial": serial, "profile_id": t.get("profile_id"), "session_id": t["session_id"], "turn_id": str(t.get("id", t.get("turn_id"))), "ts": t.get("timestamp", t.get("ts")), "role": t.get("role"), "text_enc": v.seal(t.get("text")), "ttft_ms": t.get("ttft_ms"), "total_ms": t.get("total_ms"), "voice_used": t.get("voice_used"), "model_used": t.get("model_used"), "archived_at": now, } for t in turns], ) self._db.commit() return self._db.total_changes - before def session_turns(self, serial: str, session_id: str) -> list[dict[str, Any]]: with self._lock: v = self._require_vault() rows = self._db.execute( "SELECT * FROM turns WHERE serial=? AND session_id=? ORDER BY ts, turn_id", (serial, session_id), ).fetchall() out = [] for r in rows: d = dict(r) d["text"] = v.open(d.pop("text_enc")) out.append(d) return out def last_turn_ts(self, serial: str, session_id: str | None = None) -> int | None: """Dernier timestamp archivé → borne `since` pour la collecte incrémentale (autonomie). Pas besoin du vault (métadonnée en clair).""" with self._lock: if session_id is None: row = self._db.execute( "SELECT MAX(ts) m FROM turns WHERE serial=?", (serial,) ).fetchone() else: row = self._db.execute( "SELECT MAX(ts) m FROM turns WHERE serial=? AND session_id=?", (serial, session_id), ).fetchone() return row["m"] if row else None def archived_sessions(self, serial: str) -> list[dict[str, Any]]: """Sessions archivées (métadonnées, pas de vault) : id, profil, nb tours, début/fin. Pour lister le rapatrié dans l'UI.""" with self._lock: rows = self._db.execute( "SELECT session_id, profile_id, COUNT(*) turns, MIN(ts) started_at, " "MAX(ts) ended_at, MAX(archived_at) archived_at FROM turns WHERE serial=? " "GROUP BY session_id ORDER BY started_at DESC", (serial,)).fetchall() return [dict(r) for r in rows] def purge_turns(self, serial: str, *, session_id: str | None = None, profile_id: str | None = None, before: int | None = None) -> int: """Purge (RGPD) des tours archivés selon filtre. Renvoie le nombre supprimé.""" q = "DELETE FROM turns WHERE serial=?" args: list = [serial] if session_id: q += " AND session_id=?"; args.append(session_id) if profile_id: q += " AND profile_id=?"; args.append(profile_id) if before: q += " AND ts Path: d = Path(self.path).parent / "voices" d.mkdir(parents=True, exist_ok=True) return d def _voice_upsert(self, voice_id: str, **cols: Any) -> None: """Upsert partiel d'une voix du catalogue global (colonnes données seulement).""" keys = list(cols) sets = ", ".join(f"{k}=excluded.{k}" for k in keys) self._db.execute( f"INSERT INTO voices(voice_id, {', '.join(keys)}) " f"VALUES(?, {', '.join('?' for _ in keys)}) " f"ON CONFLICT(voice_id) DO UPDATE SET {sets}", (voice_id, *[cols[k] for k in keys]), ) self._db.commit() def archive_voice_wav(self, voice_id: str, wav_bytes: bytes, *, origin_serial: str | None, source_wav_path: str | None, name: str | None = None, now: int) -> str: """Chiffre + archive le WAV d'origine sur le PC (asset global, pas lié à une tablette). Renvoie le chemin du fichier chiffré.""" v = self._require_vault() sha = hashlib.sha256(wav_bytes).hexdigest() dest = self._voices_dir() / f"{voice_id}.wav.enc" dest.write_bytes(v.seal_bytes(wav_bytes)) with self._lock: self._voice_upsert(voice_id, origin_serial=origin_serial, name=name, source_wav_path=source_wav_path, wav_file=str(dest.relative_to(self._voices_dir())), wav_sha256=sha, archived_at=now) return str(dest) def voice_wav_bytes(self, voice_id: str) -> bytes | None: """Relit le WAV archivé déchiffré (pour ré-enrôlement).""" v = self._require_vault() with self._lock: row = self._db.execute( "SELECT wav_file FROM voices WHERE voice_id=?", (voice_id,)).fetchone() if not row or not row["wav_file"]: return None return v.open_bytes((self._voices_dir() / row["wav_file"]).read_bytes()) def set_voice_transcription(self, voice_id: str, text: str, *, now: int) -> None: v = self._require_vault() with self._lock: self._voice_upsert(voice_id, transcription_enc=v.seal(text)) def store_voice_ovsp(self, voice_id: str, ovsp_bytes_data: bytes, *, now: int) -> None: """Stocke l'artefact `.ovsp` chiffré côté central → déployable sur N tablettes sans ré-enrôler. Marque la voix enrôlée.""" v = self._require_vault() with self._lock: self._voice_upsert(voice_id, ovsp_enc=v.seal_bytes(ovsp_bytes_data), ovsp_bytes=len(ovsp_bytes_data), enrolled_at=now) def voice_ovsp_bytes(self, voice_id: str) -> bytes | None: """Relit l'artefact `.ovsp` déchiffré (pour déploiement).""" v = self._require_vault() with self._lock: row = self._db.execute( "SELECT ovsp_enc FROM voices WHERE voice_id=?", (voice_id,)).fetchone() return v.open_bytes(row["ovsp_enc"]) if row and row["ovsp_enc"] else None def mark_voice_wav_deleted(self, voice_id: str, *, now: int) -> None: with self._lock: self._voice_upsert(voice_id, wav_deleted_at=now) def lock_voice(self, voice_id: str, profile_id: str, *, now: int) -> None: """Assigne une voix exclusivement à un profil patient (verrou).""" with self._lock: self._voice_upsert(voice_id, locked_profile_id=profile_id, scope="exclusive") def unlock_voice(self, voice_id: str, *, now: int) -> None: """Lève le verrou → voix redevient généraliste.""" with self._lock: self._voice_upsert(voice_id, locked_profile_id=None, scope="global") def set_voice_manifest(self, voice_id: str, *, scope: str | None, owner_name: str | None, consent_text: str | None, now: int) -> None: """Métadonnées du manifeste admin (§4.2) : portée + propriétaire + consentement (chiffré). Le consentement = phrase nominative → PII.""" v = self._require_vault() with self._lock: self._voice_upsert(voice_id, scope=scope, owner_name=owner_name, consent_text_enc=v.seal(consent_text)) def locked_profile(self, voice_id: str) -> str | None: with self._lock: row = self._db.execute( "SELECT locked_profile_id FROM voices WHERE voice_id=?", (voice_id,)).fetchone() return row["locked_profile_id"] if row else None # ---- déploiements voix↔tablette (many-to-many) ----------------------- def add_deployment(self, voice_id: str, serial: str, *, now: int) -> None: with self._lock: self._db.execute( "INSERT INTO voice_deployments(voice_id, serial, deployed_at) VALUES(?,?,?) " "ON CONFLICT(voice_id, serial) DO UPDATE SET deployed_at=excluded.deployed_at", (voice_id, serial, now)) self._db.commit() def remove_deployment(self, voice_id: str, serial: str) -> None: with self._lock: self._db.execute("DELETE FROM voice_deployments WHERE voice_id=? AND serial=?", (voice_id, serial)) self._db.commit() def deployments_of(self, voice_id: str) -> list[str]: with self._lock: return [r["serial"] for r in self._db.execute( "SELECT serial FROM voice_deployments WHERE voice_id=?", (voice_id,))] def voices_on(self, serial: str) -> set[str]: with self._lock: return {r["voice_id"] for r in self._db.execute( "SELECT voice_id FROM voice_deployments WHERE serial=?", (serial,))} # ---- lecture catalogue (global) -------------------------------------- def _voice_dict(self, row, v) -> dict[str, Any]: d = dict(row) d["transcription"] = v.open(d.pop("transcription_enc", None)) d["consent_text"] = v.open(d.pop("consent_text_enc", None)) d.pop("ovsp_enc", None) d["has_wav"] = bool(d.get("wav_file")) d["has_ovsp"] = bool(d.get("ovsp_bytes")) d["deployed_serials"] = self.deployments_of(d["voice_id"]) return d def voice_record(self, voice_id: str) -> dict[str, Any] | None: v = self._require_vault() with self._lock: row = self._db.execute( "SELECT * FROM voices WHERE voice_id=?", (voice_id,)).fetchone() return self._voice_dict(row, v) if row else None def voices(self) -> list[dict[str, Any]]: """Catalogue voix GLOBAL (toutes tablettes confondues).""" v = self._require_vault() with self._lock: rows = self._db.execute("SELECT * FROM voices ORDER BY voice_id").fetchall() return [self._voice_dict(r, v) for r in rows] # ---- dossier patient (médical, chiffré) ------------------------------- def save_fiche(self, serial: str, profile_id: str, fiche: dict[str, Any], language: str | None, *, now: int) -> None: """Enregistre la fiche clinique chiffrée (identité/contact/clinique). Le contenu = PII santé → chiffré ; `language` en clair (miroir opérationnel).""" v = self._require_vault() with self._lock: self._db.execute( "INSERT INTO patient_fiche(serial, profile_id, fiche_enc, language, updated_at) " "VALUES(?,?,?,?,?) ON CONFLICT(serial, profile_id) DO UPDATE SET " "fiche_enc=excluded.fiche_enc, language=excluded.language, updated_at=excluded.updated_at", (serial, profile_id, v.seal(json.dumps(fiche)), language, now)) self._db.commit() def set_fiche_language(self, serial: str, profile_id: str, language: str | None, *, now: int) -> None: with self._lock: self._db.execute( "INSERT INTO patient_fiche(serial, profile_id, language, updated_at) VALUES(?,?,?,?) " "ON CONFLICT(serial, profile_id) DO UPDATE SET language=excluded.language, updated_at=excluded.updated_at", (serial, profile_id, language, now)) self._db.commit() def fiche(self, serial: str, profile_id: str) -> dict[str, Any] | None: v = self._require_vault() with self._lock: row = self._db.execute( "SELECT * FROM patient_fiche WHERE serial=? AND profile_id=?", (serial, profile_id)).fetchone() if not row: return None fx = v.open(row["fiche_enc"]) return {"fiche": json.loads(fx) if fx else {}, "language": row["language"], "updated_at": row["updated_at"]} def fiches(self, serial: str) -> dict[str, dict[str, Any]]: v = self._require_vault() with self._lock: rows = self._db.execute( "SELECT * FROM patient_fiche WHERE serial=?", (serial,)).fetchall() out = {} for r in rows: fx = v.open(r["fiche_enc"]) 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: with self._lock: self._db.execute( "INSERT INTO audit(ts, actor, action, target, detail) VALUES(?,?,?,?,?)", (now, actor, action, target, json.dumps(detail) if detail else None), ) self._db.commit() def audit_log(self, limit: int = 200) -> list[dict[str, Any]]: with self._lock: return [dict(r) for r in self._db.execute( "SELECT * FROM audit ORDER BY id DESC LIMIT ?", (limit,) )]