feat(voice): verrou voix↔profil + sync verrou-conscient + déclencheur auto

- store : colonne locked_profile_id (+ migration ALTER idempotente), lock_voice/
  unlock_voice. Verrou = assignation exclusive à un profil ; NULL = voix généraliste.
- orchestrator : list_voices expose locked_profile_id + used_by_profiles (croise
  profile.voice_id) ; sync_device enrôle+déploie les voix en attente, REFUSE de
  déployer une voix verrouillée sur une tablette sans le profil propriétaire.
- autosync : VoiceAutoSync — watcher de connexions armable depuis l'UI (désarmé par
  défaut), statut par tablette, ne fait rien si store verrouillé (PII), delete opt-in.
- API : lock/unlock, POST /{serial}/sync, GET/POST /voices/autosync (déclarées AVANT
  /{serial} pour éviter la capture "autosync" comme serial).
- tests : +8 (verrou, sync verrou-conscient, autosync). 39/39.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
alf 2026-06-19 16:20:55 +02:00
parent 4ad513afda
commit 23033384c7
7 changed files with 288 additions and 5 deletions

View File

@ -26,6 +26,7 @@ from ..fleet import device_overview, fan_out
from ..provider import ProviderClient from ..provider import ProviderClient
from ..store import BadPassword, Store, StoreLocked from ..store import BadPassword, Store, StoreLocked
from ..voice import orchestrator as vo from ..voice import orchestrator as vo
from ..voice.autosync import VoiceAutoSync
from ..voice.bridge import VoiceBridge, VoiceWorkerError from ..voice.bridge import VoiceBridge, VoiceWorkerError
_WEB_DIR = Path(__file__).resolve().parent.parent / "web" _WEB_DIR = Path(__file__).resolve().parent.parent / "web"
@ -52,6 +53,19 @@ class _Enroll(BaseModel):
delete_source: bool = False # suppression du WAV device = opt-in explicite delete_source: bool = False # suppression du WAV device = opt-in explicite
class _Sync(BaseModel):
delete_source: bool = False
class _AutoSync(BaseModel):
enabled: bool
delete_source: bool | None = None
class _Lock(BaseModel):
profile_id: str
def _install_basic_auth(app: FastAPI) -> None: def _install_basic_auth(app: FastAPI) -> None:
"""Auth HTTP Basic optionnelle, active SEULEMENT si KAZEIA_USER+KAZEIA_PASS """Auth HTTP Basic optionnelle, active SEULEMENT si KAZEIA_USER+KAZEIA_PASS
sont définis. Couvre tout (UI statique incluse) via middleware. Pensée pour sont définis. Couvre tout (UI statique incluse) via middleware. Pensée pour
@ -78,12 +92,15 @@ def _install_basic_auth(app: FastAPI) -> None:
def create_app(adb: Adb | None = None, store: Store | None = None, def create_app(adb: Adb | None = None, store: Store | None = None,
bridge: VoiceBridge | None = None) -> FastAPI: bridge: VoiceBridge | None = None, *, start_autosync: bool = True) -> FastAPI:
adb = adb or Adb() adb = adb or Adb()
if store is None: if store is None:
_STORE_PATH.parent.mkdir(parents=True, exist_ok=True) _STORE_PATH.parent.mkdir(parents=True, exist_ok=True)
store = Store(_STORE_PATH) store = Store(_STORE_PATH)
bridge = bridge or VoiceBridge() bridge = bridge or VoiceBridge()
autosync = VoiceAutoSync(adb, store, bridge)
if start_autosync:
autosync.start() # désarmé par défaut : ne fait que suivre les connexions
app = FastAPI(title="Kazeia-central", version="0.0.1") app = FastAPI(title="Kazeia-central", version="0.0.1")
_install_basic_auth(app) _install_basic_auth(app)
@ -214,6 +231,20 @@ def create_app(adb: Adb | None = None, store: Store | None = None,
def voices_health(): def voices_health():
return {"encoder_available": bridge.available(), "cv_python": bridge.cv_python} return {"encoder_available": bridge.available(), "cv_python": bridge.cv_python}
# ⚠️ Routes littérales sous /api/voices/ AVANT /{serial} sinon "autosync" est
# capté comme un serial (FastAPI matche dans l'ordre de déclaration).
@app.get("/api/voices/autosync")
def autosync_status():
return autosync.status()
@app.post("/api/voices/autosync")
def autosync_set(body: _AutoSync):
autosync.set_enabled(body.enabled, delete_source=body.delete_source)
store.audit("autosync_set", actor=_OPERATOR,
detail={"enabled": body.enabled, "delete_source": autosync.delete_source},
now=_now_ms())
return autosync.status()
@app.get("/api/voices/{serial}") @app.get("/api/voices/{serial}")
def voices_list(serial: str): def voices_list(serial: str):
return _guard(lambda: vo.list_voices(adb, store, serial)) return _guard(lambda: vo.list_voices(adb, store, serial))
@ -230,6 +261,25 @@ def create_app(adb: Adb | None = None, store: Store | None = None,
adb, store, bridge, serial, voice_id, body.transcription, adb, store, bridge, serial, voice_id, body.transcription,
delete_source=body.delete_source, now=_now_ms(), actor=_OPERATOR)) delete_source=body.delete_source, now=_now_ms(), actor=_OPERATOR))
@app.post("/api/voices/{serial}/{voice_id}/lock")
def voices_lock(serial: str, voice_id: str, body: _Lock):
_guard(lambda: store.lock_voice(serial, voice_id, body.profile_id, now=_now_ms()))
store.audit("voice_lock", actor=_OPERATOR, target=f"{serial}/{voice_id}",
detail={"profile_id": body.profile_id}, now=_now_ms())
return {"voice_id": voice_id, "locked_profile_id": body.profile_id}
@app.post("/api/voices/{serial}/{voice_id}/unlock")
def voices_unlock(serial: str, voice_id: str):
_guard(lambda: store.unlock_voice(serial, voice_id, now=_now_ms()))
store.audit("voice_unlock", actor=_OPERATOR, target=f"{serial}/{voice_id}", now=_now_ms())
return {"voice_id": voice_id, "locked_profile_id": None}
@app.post("/api/voices/{serial}/sync")
def voices_sync(serial: str, body: _Sync):
return _guard(lambda: vo.sync_device(
adb, store, bridge, serial, delete_source=body.delete_source,
now=_now_ms(), actor=_OPERATOR))
# UI web locale servie à la racine — APRÈS les routes /api pour ne pas les # UI web locale servie à la racine — APRÈS les routes /api pour ne pas les
# masquer. `html=True` → sert index.html sur "/". # masquer. `html=True` → sert index.html sur "/".
if _WEB_DIR.is_dir(): if _WEB_DIR.is_dir():

View File

@ -78,6 +78,7 @@ CREATE TABLE IF NOT EXISTS voices (
enrolled_at INTEGER, -- .cvps produit enrolled_at INTEGER, -- .cvps produit
deployed_at INTEGER, -- .cvps poussé sur la tablette deployed_at INTEGER, -- .cvps poussé sur la tablette
wav_deleted_at INTEGER, -- WAV supprimé de la tablette wav_deleted_at INTEGER, -- WAV supprimé de la tablette
locked_profile_id TEXT, -- assignation exclusive à un profil patient (NULL = généraliste)
PRIMARY KEY (serial, voice_id) PRIMARY KEY (serial, voice_id)
); );
""" """
@ -102,9 +103,17 @@ class Store:
self._db.execute("PRAGMA journal_mode=WAL") self._db.execute("PRAGMA journal_mode=WAL")
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._db.commit() self._db.commit()
self._vault: Vault | None = None self._vault: Vault | None = None
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 "locked_profile_id" not in cols:
self._db.execute("ALTER TABLE voices ADD COLUMN locked_profile_id TEXT")
def close(self) -> None: def close(self) -> None:
with self._lock: with self._lock:
self._db.close() self._db.close()
@ -330,6 +339,23 @@ class Store:
with self._lock: with self._lock:
self._voice_upsert(serial, voice_id, wav_deleted_at=now) self._voice_upsert(serial, voice_id, wav_deleted_at=now)
def lock_voice(self, serial: str, voice_id: str, profile_id: str, *, now: int) -> None:
"""Assigne une voix exclusivement à un profil patient (verrou)."""
with self._lock:
self._voice_upsert(serial, voice_id, locked_profile_id=profile_id)
def unlock_voice(self, serial: str, voice_id: str, *, now: int) -> None:
"""Lève le verrou → voix redevient généraliste."""
with self._lock:
self._voice_upsert(serial, voice_id, locked_profile_id=None)
def locked_profile(self, serial: str, voice_id: str) -> str | None:
with self._lock:
row = self._db.execute(
"SELECT locked_profile_id FROM voices WHERE serial=? AND voice_id=?",
(serial, voice_id)).fetchone()
return row["locked_profile_id"] if row else None
def voice_record(self, serial: str, voice_id: str) -> dict[str, Any] | None: def voice_record(self, serial: str, voice_id: str) -> dict[str, Any] | None:
v = self._require_vault() v = self._require_vault()
with self._lock: with self._lock:

View File

@ -0,0 +1,97 @@
"""Déclencheur automatique d'enrôlement à la connexion d'une tablette.
Piloté depuis l'interface : démarre **désarmé** (`enabled=False`). Quand l'opérateur
l'arme, toute tablette qui se **connecte** déclenche un `sync_device` (enrôle +
déploie les voix en attente, verrou-conscient). Statut par tablette consultable.
Sécurité : ne fait rien si le store est verrouillé (les PII restent protégées) ; la
suppression du WAV device est opt-in (`delete_source`, défaut False). Détecte les
**nouvelles** connexions ; pour traiter une tablette déjà branchée, l'UI utilise le
bouton « synchroniser » (endpoint sync manuel).
"""
from __future__ import annotations
import threading
import time
from typing import Any
from .orchestrator import sync_device
class VoiceAutoSync:
def __init__(self, adb, store, bridge, *, interval: float = 3.0) -> None:
self.adb = adb
self.store = store
self.bridge = bridge
self.interval = interval
self.enabled = False
self.delete_source = False
self._known: set[str] = set()
self._status: dict[str, dict] = {}
self._lock = threading.Lock()
self._thread: threading.Thread | None = None
self._stop = threading.Event()
@staticmethod
def _now() -> int:
return int(time.time() * 1000)
# ---- cycle de vie -----------------------------------------------------
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(target=self._loop, name="voice-autosync", daemon=True)
self._thread.start()
def stop(self) -> None:
self._stop.set()
def set_enabled(self, enabled: bool, *, delete_source: bool | None = None) -> None:
self.enabled = bool(enabled)
if delete_source is not None:
self.delete_source = bool(delete_source)
def status(self) -> dict[str, Any]:
with self._lock:
return {"enabled": self.enabled, "delete_source": self.delete_source,
"running": bool(self._thread and self._thread.is_alive()),
"devices": dict(self._status)}
# ---- boucle -----------------------------------------------------------
def _loop(self) -> None:
while not self._stop.wait(self.interval):
try:
ready = {d.serial for d in self.adb.ready_devices()}
except Exception:
continue
new = ready - self._known
self._known = ready
if not self.enabled:
continue
for serial in sorted(new):
self.sync_now(serial)
def sync_now(self, serial: str) -> dict:
"""Lance une sync pour une tablette (utilisé par le watcher ET par le bouton
manuel). Ne lève pas : renvoie/enregistre un statut."""
if not self.store.is_unlocked:
st = {"state": "store_verrouille", "at": self._now()}
self._set(serial, st)
return st
self._set(serial, {"state": "sync_en_cours", "at": self._now()})
try:
rep = sync_device(self.adb, self.store, self.bridge, serial,
delete_source=self.delete_source, now=self._now(), actor="auto")
ok = sum(1 for r in rep if r.get("ok"))
st = {"state": "termine", "at": self._now(), "enrolled": ok,
"total": len(rep), "report": rep}
except Exception as e:
st = {"state": "erreur", "at": self._now(), "error": str(e)}
self._set(serial, st)
return st
def _set(self, serial: str, st: dict) -> None:
with self._lock:
self._status[serial] = st

View File

@ -47,16 +47,33 @@ def _status(deployed: bool, rec: dict) -> str:
return "pending" return "pending"
def _profiles_using(adb: Adb, serial: str) -> tuple[dict[str, list[str]], set[str]]:
"""Renvoie ({voice_id: [profil_id,...]}, {profils présents}). Croise profile.voice_id."""
used: dict[str, list[str]] = {}
present: set[str] = set()
try:
for p in ProviderClient(adb, serial).profiles():
present.add(p.id)
if p.voice_id:
used.setdefault(p.voice_id, []).append(p.id)
except Exception:
pass
return used, present
def list_voices(adb: Adb, store: Store, serial: str) -> list[dict[str, Any]]: def list_voices(adb: Adb, store: Store, serial: str) -> list[dict[str, Any]]:
"""Inventaire voix de la tablette croisé avec l'archive store (statut par voix).""" """Inventaire voix de la tablette croisé avec l'archive store (statut, verrou,
profils utilisateurs) par voix."""
rows = _provider_voices(adb, serial) rows = _provider_voices(adb, serial)
cosy = transfer.cosyvoice_dir_for(rows[0].wav_path) if rows else None cosy = transfer.cosyvoice_dir_for(rows[0].wav_path) if rows else None
deployed = transfer.deployed_cvps(adb, serial, cosy) if cosy else set() deployed = transfer.deployed_cvps(adb, serial, cosy) if cosy else set()
recs = {r["voice_id"]: r for r in store.voices(serial)} if store.is_unlocked else {} recs = {r["voice_id"]: r for r in store.voices(serial)} if store.is_unlocked else {}
used_by, _ = _profiles_using(adb, serial)
out = [] out = []
for v in rows: for v in rows:
rec = recs.get(v.id, {}) rec = recs.get(v.id, {})
is_dep = v.id in deployed is_dep = v.id in deployed
locked = rec.get("locked_profile_id")
out.append({ out.append({
"voice_id": v.id, "voice_id": v.id,
"wav_path": v.wav_path, "wav_path": v.wav_path,
@ -67,11 +84,42 @@ def list_voices(adb: Adb, store: Store, serial: str) -> list[dict[str, Any]]:
"wav_deleted": bool(rec.get("wav_deleted_at")), "wav_deleted": bool(rec.get("wav_deleted_at")),
"transcription": rec.get("transcription"), "transcription": rec.get("transcription"),
"language": rec.get("language"), "language": rec.get("language"),
"locked_profile_id": locked, # exclusif à ce profil, ou None = généraliste
"used_by_profiles": used_by.get(v.id, []), # profils qui sélectionnent cette voix
"status": _status(is_dep, rec), "status": _status(is_dep, rec),
}) })
return out return out
def sync_device(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, *,
delete_source: bool, now: int, actor: str | None = None) -> list[dict[str, Any]]:
"""Enrôle + déploie les voix en attente d'une tablette (rapport par voix).
Verrou-conscient : ne déploie pas une voix exclusive sur une tablette dépourvue
du profil propriétaire. Transcrit automatiquement si pas encore relu (la relecture
fine reste possible voix par voix via prepare/enroll)."""
_, present = _profiles_using(adb, serial)
report: list[dict[str, Any]] = []
for v in list_voices(adb, store, serial):
vid = v["voice_id"]
if v["deployed_on_device"]:
continue
lock = v["locked_profile_id"]
if lock and lock not in present:
report.append({"voice_id": vid, "ok": False,
"skipped": "verrou_profil_absent", "profile": lock})
continue
try:
text = v["transcription"]
if not text:
text = prepare(adb, store, bridge, serial, vid, now=now, actor=actor)["transcription"]
res = enroll_deploy(adb, store, bridge, serial, vid, text,
delete_source=delete_source, now=now, actor=actor)
report.append({"voice_id": vid, "ok": True, **res})
except Exception as e:
report.append({"voice_id": vid, "ok": False, "error": str(e)})
return report
def _ensure_wav_archived(adb: Adb, store: Store, serial: str, voice_id: str, *, def _ensure_wav_archived(adb: Adb, store: Store, serial: str, voice_id: str, *,
now: int) -> bytes: now: int) -> bytes:
"""Garantit que le WAV est archivé (chiffré) côté PC ; le renvoie déchiffré.""" """Garantit que le WAV est archivé (chiffré) côté PC ; le renvoie déchiffré."""

View File

@ -22,7 +22,7 @@ class _FakeAdb(Adb):
def _client(tmp_path): def _client(tmp_path):
store = Store(tmp_path / "central.db", kdf_ops=_OPS, kdf_mem=_MEM) store = Store(tmp_path / "central.db", kdf_ops=_OPS, kdf_mem=_MEM)
return TestClient(create_app(adb=_FakeAdb(), store=store)) return TestClient(create_app(adb=_FakeAdb(), store=store, start_autosync=False))
def test_lock_unlock_flow(tmp_path): def test_lock_unlock_flow(tmp_path):

View File

@ -80,7 +80,7 @@ def test_fleet_overview_endpoint_merges_labels(tmp_path):
store = Store(tmp_path / "c.db", store = Store(tmp_path / "c.db",
kdf_ops=nacl.pwhash.argon2id.OPSLIMIT_MIN, kdf_ops=nacl.pwhash.argon2id.OPSLIMIT_MIN,
kdf_mem=nacl.pwhash.argon2id.MEMLIMIT_MIN) kdf_mem=nacl.pwhash.argon2id.MEMLIMIT_MIN)
c = TestClient(create_app(adb=_ReadyAdb(["D1"], _CANNED), store=store)) c = TestClient(create_app(adb=_ReadyAdb(["D1"], _CANNED), store=store, start_autosync=False))
c.post("/api/unlock", json={"password": "pw"}) c.post("/api/unlock", json={"password": "pw"})
c.put("/api/devices/D1/label", json={"label": "Mme D."}) c.put("/api/devices/D1/label", json={"label": "Mme D."})
rep = c.get("/api/fleet/overview").json() rep = c.get("/api/fleet/overview").json()

View File

@ -67,10 +67,11 @@ def test_voice_status_marks(tmp_path):
# ---- orchestration (faux adb + faux bridge) ------------------------------ # ---- orchestration (faux adb + faux bridge) ------------------------------
class _VoiceAdb(Adb): class _VoiceAdb(Adb):
def __init__(self, voices, deployed=()): def __init__(self, voices, deployed=(), profiles=()):
super().__init__() super().__init__()
self._voices = voices # [(id, wav_path, wav_exists)] self._voices = voices # [(id, wav_path, wav_exists)]
self._deployed = list(deployed) self._deployed = list(deployed)
self._profiles = list(profiles) # [(profile_id, voice_id)]
self.pushed = [] self.pushed = []
self.deleted = [] self.deleted = []
@ -80,6 +81,8 @@ class _VoiceAdb(Adb):
def content_query(self, path, *, serial=None, where=None, expect_rows=False, _retry=True): def content_query(self, path, *, serial=None, where=None, expect_rows=False, _retry=True):
if path == "voices": if path == "voices":
return [{"id": i, "wav_path": w, "wav_exists": 1} for i, w, _ in self._voices] return [{"id": i, "wav_path": w, "wav_exists": 1} for i, w, _ in self._voices]
if path == "profiles":
return [{"id": pid, "voice_id": vid} for pid, vid in self._profiles]
return [] return []
def shell(self, command, *, serial=None, timeout=None): def shell(self, command, *, serial=None, timeout=None):
@ -145,3 +148,62 @@ def test_enroll_deploy_pushes_and_optional_delete(tmp_path):
delete_source=True, now=NOW) delete_source=True, now=NOW)
assert r2["wav_deleted"] is True and adb.deleted == [_WP] assert r2["wav_deleted"] is True and adb.deleted == [_WP]
assert s.voice_record("D1", "amir")["wav_deleted_at"] == NOW assert s.voice_record("D1", "amir")["wav_deleted_at"] == NOW
# ---- verrou voix ↔ profil (assignation exclusive + généralistes) ---------
def test_lock_unlock_voice(tmp_path):
s = _store(tmp_path)
s.archive_voice_wav("D1", "amir", b"x", source_wav_path=None, now=NOW)
s.lock_voice("D1", "amir", "patient_07", now=NOW)
assert s.voice_record("D1", "amir")["locked_profile_id"] == "patient_07"
s.unlock_voice("D1", "amir", now=NOW)
assert s.voice_record("D1", "amir")["locked_profile_id"] is None
def test_list_voices_shows_lock_and_users(tmp_path):
s = _store(tmp_path)
adb = _VoiceAdb([("amir", _WP, 1)], profiles=[("p1", "amir"), ("p2", "amir")])
s.archive_voice_wav("D1", "amir", b"x", source_wav_path=_WP, now=NOW)
s.lock_voice("D1", "amir", "p1", now=NOW)
v = vo.list_voices(adb, s, "D1")[0]
assert v["locked_profile_id"] == "p1"
assert set(v["used_by_profiles"]) == {"p1", "p2"}
def test_sync_skips_voice_locked_to_absent_profile(tmp_path):
s = _store(tmp_path)
# voix verrouillée sur un profil ABSENT de la tablette → non déployée
adb = _VoiceAdb([("amir", _WP, 1)], profiles=[("p_present", "x")])
s.archive_voice_wav("D1", "amir", b"WAV", source_wav_path=_WP, now=NOW)
s.lock_voice("D1", "amir", "p_absent", now=NOW)
rep = vo.sync_device(adb, s, _FakeBridge(), "D1", delete_source=False, now=NOW)
assert rep[0]["ok"] is False and rep[0]["skipped"] == "verrou_profil_absent"
assert adb.pushed == [] # rien poussé
def test_sync_deploys_general_voice(tmp_path):
s = _store(tmp_path)
adb = _VoiceAdb([("amir", _WP, 1)]) # généraliste (non verrouillée)
rep = vo.sync_device(adb, s, _FakeBridge(), "D1", delete_source=False, now=NOW)
assert rep[0]["ok"] is True and adb.pushed
assert s.voice_record("D1", "amir")["deployed_at"] == NOW
# ---- déclencheur auto -----------------------------------------------------
def test_autosync_blocked_when_store_locked(tmp_path):
from kazeia_central.store import Store
from kazeia_central.voice.autosync import VoiceAutoSync
locked_store = Store(tmp_path / "locked.db", kdf_ops=_OPS, kdf_mem=_MEM) # PAS unlock
a = VoiceAutoSync(_VoiceAdb([("amir", _WP, 1)]), locked_store, _FakeBridge())
st = a.sync_now("D1")
assert st["state"] == "store_verrouille"
def test_autosync_enabled_runs_sync(tmp_path):
from kazeia_central.voice.autosync import VoiceAutoSync
s = _store(tmp_path)
a = VoiceAutoSync(_VoiceAdb([("amir", _WP, 1)]), s, _FakeBridge())
a.set_enabled(True, delete_source=False)
assert a.status()["enabled"] is True
st = a.sync_now("D1")
assert st["state"] == "termine" and st["enrolled"] == 1