From 6640cfd79e4fd02a25a9af2d8982dbcb8d1530c7 Mon Sep 17 00:00:00 2001 From: alf Date: Tue, 23 Jun 2026 19:39:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(voice):=20catalogue=20GLOBAL=20(flotte)=20?= =?UTF-8?q?+=20d=C3=A9ploiement=20par=20tablette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refonte : une voix n'est plus liée à sa tablette d'enregistrement. Catalogue central clé voice_id, .ovsp stocké chiffré → déployable sur N tablettes sans ré-enrôler. - store : voices PK voice_id (+ origin_serial, ovsp_enc) + table voice_deployments (many-to-many) + migration de l'ancien schéma (serial,voice_id)→global. Méthodes globales + add/remove_deployment, voices_on, deployments_of, store/voice_ovsp_bytes. - orchestrator : catalog(), deploy_voice/undeploy_voice (scope-conscient), list_for_tablet (chargées + chargeables), ingest_voice/sync_admin au catalogue global. Suppr. legacy /voices-based (list_voices/prepare/enroll_deploy/sync_device). - API : GET /voices/catalog, POST /voices/catalog/{vid}/lock|unlock, GET /voices/{serial} (chargées+chargeables), POST /voices/{serial}/deploy|undeploy. - UI : carte Catalogue flotte (visible sans tablette) + par tablette chargées/chargeables avec charger/retirer. - tests réécrits (40/40). Validé live : catalog [], /voices/{serial} chargées+chargeables. Co-Authored-By: Claude Opus 4.8 (1M context) --- kazeia_central/api/app.py | 72 ++--- kazeia_central/store/db.py | 222 +++++++++------ kazeia_central/voice/autosync.py | 2 +- kazeia_central/voice/orchestrator.py | 361 +++++++++--------------- kazeia_central/web/index.html | 136 +++++---- tests/test_voice.py | 404 ++++++++++----------------- 6 files changed, 540 insertions(+), 657 deletions(-) diff --git a/kazeia_central/api/app.py b/kazeia_central/api/app.py index 57ce2a0..a673734 100644 --- a/kazeia_central/api/app.py +++ b/kazeia_central/api/app.py @@ -66,6 +66,10 @@ class _Lock(BaseModel): profile_id: str +class _Deploy(BaseModel): + voice_id: str + + def _install_basic_auth(app: FastAPI) -> None: """Auth HTTP Basic optionnelle, active SEULEMENT si KAZEIA_USER+KAZEIA_PASS sont définis. Couvre tout (UI statique incluse) via middleware. Pensée pour @@ -231,8 +235,8 @@ def create_app(adb: Adb | None = None, store: Store | None = None, def voices_health(): return {"encoder_available": bridge.available(), "ov_python": bridge.ov_python} - # ⚠️ Routes littérales sous /api/voices/ AVANT /{serial} sinon "autosync" est - # capté comme un serial (FastAPI matche dans l'ordre de déclaration). + # ⚠️ Routes littérales sous /api/voices/ AVANT /{serial} (sinon "autosync"/"catalog" + # captés comme un serial — FastAPI matche dans l'ordre de déclaration). @app.get("/api/voices/autosync") def autosync_status(): return autosync.status() @@ -245,12 +249,29 @@ def create_app(adb: Adb | None = None, store: Store | None = None, now=_now_ms()) return autosync.status() - @app.get("/api/voices/{serial}") - def voices_list(serial: str): - return _guard(lambda: vo.list_voices(adb, store, serial)) + # ---- catalogue voix GLOBAL (vue sans tablette) ------------------------ + @app.get("/api/voices/catalog") + def voices_catalog(): + return _guard(lambda: vo.catalog(store)) + + @app.post("/api/voices/catalog/{voice_id}/lock") + def voices_lock(voice_id: str, body: _Lock): + _guard(lambda: store.lock_voice(voice_id, body.profile_id, now=_now_ms())) + store.audit("voice_lock", actor=_OPERATOR, target=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/catalog/{voice_id}/unlock") + def voices_unlock(voice_id: str): + _guard(lambda: store.unlock_voice(voice_id, now=_now_ms())) + store.audit("voice_unlock", actor=_OPERATOR, target=voice_id, now=_now_ms()) + return {"voice_id": voice_id, "locked_profile_id": None} + + # ---- vue par tablette : voix chargées + chargeables ------------------- + @app.get("/api/voices/{serial}") + def voices_for_tablet(serial: str): + return _guard(lambda: vo.list_for_tablet(adb, store, serial)) - # Voix enregistrées via l'admin (manifestes record-time : scope/owner/consentement). - # Déclarées avant /{serial}/{voice_id}/... — "admin" ne doit pas être pris pour un id. @app.get("/api/voices/{serial}/admin") def voices_admin_list(serial: str): return _guard(lambda: vo.list_admin_voices(adb, store, serial)) @@ -260,36 +281,15 @@ def create_app(adb: Adb | None = None, store: Store | None = None, return _guard(lambda: vo.sync_admin(adb, store, bridge, serial, delete_source=body.delete_source, now=_now_ms(), actor=_OPERATOR)) - @app.post("/api/voices/{serial}/{voice_id}/prepare") - def voices_prepare(serial: str, voice_id: str): - # pull + archive chiffré + transcription (ASR OmniVoice, à relire avant enrôlement) - return _guard(lambda: vo.prepare(adb, store, bridge, serial, voice_id, - now=_now_ms(), actor=_OPERATOR)) + @app.post("/api/voices/{serial}/deploy") + def voices_deploy(serial: str, body: _Deploy): + return _guard(lambda: vo.deploy_voice(adb, store, bridge, serial, body.voice_id, + now=_now_ms(), actor=_OPERATOR)) - @app.post("/api/voices/{serial}/{voice_id}/enroll") - def voices_enroll(serial: str, voice_id: str, body: _Enroll): - return _guard(lambda: vo.enroll_deploy( - adb, store, bridge, serial, voice_id, body.transcription, - 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)) + @app.post("/api/voices/{serial}/undeploy") + def voices_undeploy(serial: str, body: _Deploy): + return _guard(lambda: vo.undeploy_voice(adb, store, serial, body.voice_id, + now=_now_ms(), actor=_OPERATOR)) # UI web locale servie à la racine — APRÈS les routes /api pour ne pas les # masquer. `html=True` → sert index.html sur "/". diff --git a/kazeia_central/store/db.py b/kazeia_central/store/db.py index dfdb48d..b07b282 100644 --- a/kazeia_central/store/db.py +++ b/kazeia_central/store/db.py @@ -65,24 +65,33 @@ CREATE TABLE IF NOT EXISTS audit ( 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 ( - serial TEXT NOT NULL, - voice_id TEXT NOT NULL, - transcription_enc BLOB, -- texte parlé (PII possible) → chiffré - language TEXT, - source_wav_path TEXT, -- chemin device d'origine - wav_file TEXT, -- WAV chiffré archivé (relatif au dossier voices/) - wav_sha256 TEXT, -- intégrité du WAV d'origine + 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 - deployed_at INTEGER, -- .ovsp poussé sur la tablette - wav_deleted_at INTEGER, -- WAV supprimé de la tablette - locked_profile_id TEXT, -- assignation exclusive à un profil patient (NULL = généraliste) - scope TEXT, -- exclusive | global | pending (du manifeste admin §4.2) - owner_name TEXT, -- libellé propriétaire (manifeste) - consent_text_enc BLOB, -- reference_text = phrase de consentement (PII) → chiffré - PRIMARY KEY (serial, voice_id) + 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) ); """ @@ -114,16 +123,35 @@ class Store: """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") - # Migration OmniVoice (23/06/2026) : cvps_bytes → ovsp_bytes. - if "cvps_bytes" in cols and "ovsp_bytes" not in cols: - self._db.execute("ALTER TABLE voices RENAME COLUMN cvps_bytes TO ovsp_bytes") - # Ingestion manifeste admin (§4.2) : scope/propriétaire/consentement. - 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}") + 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: @@ -293,120 +321,142 @@ class Store: d.mkdir(parents=True, exist_ok=True) return d - def _voice_upsert(self, serial: str, voice_id: str, **cols: Any) -> None: - """Upsert partiel d'une ligne voix (colonnes données seulement).""" + 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(serial, voice_id, {', '.join(keys)}) " - f"VALUES(?, ?, {', '.join('?' for _ in keys)}) " - f"ON CONFLICT(serial, voice_id) DO UPDATE SET {sets}", - (serial, voice_id, *[cols[k] for k in keys]), + 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, serial: str, voice_id: str, wav_bytes: bytes, *, - source_wav_path: str | None, now: int) -> str: - """Chiffre + archive le WAV d'origine sur le PC (le « garder sur Kazeia-central » - une fois supprimé de la tablette). Renvoie le chemin du fichier chiffré.""" + 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() / serial / f"{voice_id}.wav.enc" - dest.parent.mkdir(parents=True, exist_ok=True) + dest = self._voices_dir() / f"{voice_id}.wav.enc" dest.write_bytes(v.seal_bytes(wav_bytes)) with self._lock: - self._voice_upsert(serial, voice_id, source_wav_path=source_wav_path, + 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, serial: str, voice_id: str) -> bytes | None: + 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 serial=? AND voice_id=?", - (serial, voice_id)).fetchone() + "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, serial: str, voice_id: str, text: str, - language: str | None, *, now: int) -> None: + def set_voice_transcription(self, voice_id: str, text: str, *, now: int) -> None: v = self._require_vault() with self._lock: - self._voice_upsert(serial, voice_id, transcription_enc=v.seal(text), - language=language) + self._voice_upsert(voice_id, transcription_enc=v.seal(text)) - def mark_voice_enrolled(self, serial: str, voice_id: str, ovsp_bytes: int, *, now: int) -> None: + 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(serial, voice_id, ovsp_bytes=ovsp_bytes, enrolled_at=now) + self._voice_upsert(voice_id, ovsp_enc=v.seal_bytes(ovsp_bytes_data), + ovsp_bytes=len(ovsp_bytes_data), enrolled_at=now) - def mark_voice_deployed(self, serial: str, voice_id: str, *, now: int) -> None: + 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: - self._voice_upsert(serial, voice_id, deployed_at=now) + 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, serial: str, voice_id: str, *, now: int) -> None: + def mark_voice_wav_deleted(self, voice_id: str, *, now: int) -> None: with self._lock: - self._voice_upsert(serial, voice_id, wav_deleted_at=now) + self._voice_upsert(voice_id, wav_deleted_at=now) - def lock_voice(self, serial: str, voice_id: str, profile_id: str, *, now: int) -> None: + 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(serial, voice_id, locked_profile_id=profile_id) + self._voice_upsert(voice_id, locked_profile_id=profile_id, scope="exclusive") - def unlock_voice(self, serial: str, voice_id: str, *, now: int) -> None: + 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(serial, voice_id, locked_profile_id=None) + self._voice_upsert(voice_id, locked_profile_id=None, scope="global") - def set_voice_manifest(self, serial: str, voice_id: str, *, scope: str | None, + 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(serial, voice_id, scope=scope, owner_name=owner_name, + self._voice_upsert(voice_id, scope=scope, owner_name=owner_name, consent_text_enc=v.seal(consent_text)) - def locked_profile(self, serial: str, voice_id: str) -> str | None: + def locked_profile(self, 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() + "SELECT locked_profile_id FROM voices WHERE voice_id=?", (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: + # ---- 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 serial=? AND voice_id=?", - (serial, voice_id)).fetchone() - if not row: - return None - d = dict(row) - d["transcription"] = v.open(d.pop("transcription_enc")) - d["consent_text"] = v.open(d.pop("consent_text_enc", None)) - d["has_wav"] = bool(d.get("wav_file")) - return d + "SELECT * FROM voices WHERE voice_id=?", (voice_id,)).fetchone() + return self._voice_dict(row, v) if row else None - def voices(self, serial: str | None = None) -> list[dict[str, Any]]: + def voices(self) -> list[dict[str, Any]]: + """Catalogue voix GLOBAL (toutes tablettes confondues).""" v = self._require_vault() with self._lock: - q = "SELECT * FROM voices" - args: tuple = () - if serial is not None: - q += " WHERE serial=?" - args = (serial,) - rows = self._db.execute(q + " ORDER BY serial, voice_id", args).fetchall() - out = [] - for r in rows: - d = dict(r) - d["transcription"] = v.open(d.pop("transcription_enc")) - d["consent_text"] = v.open(d.pop("consent_text_enc", None)) - d["has_wav"] = bool(d.get("wav_file")) - out.append(d) - return out + rows = self._db.execute("SELECT * FROM voices ORDER BY voice_id").fetchall() + return [self._voice_dict(r, v) for r in rows] # ---- audit (§8) ------------------------------------------------------- def audit(self, action: str, *, actor: str | None = None, target: str | None = None, diff --git a/kazeia_central/voice/autosync.py b/kazeia_central/voice/autosync.py index 59c3a40..da25384 100644 --- a/kazeia_central/voice/autosync.py +++ b/kazeia_central/voice/autosync.py @@ -1,7 +1,7 @@ """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 + +l'arme, toute tablette qui se **connecte** déclenche un `sync_admin` (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 diff --git a/kazeia_central/voice/orchestrator.py b/kazeia_central/voice/orchestrator.py index 0eedec4..e6720ef 100644 --- a/kazeia_central/voice/orchestrator.py +++ b/kazeia_central/voice/orchestrator.py @@ -1,16 +1,14 @@ -"""Orchestration de l'enrôlement vocal (le workflow complet). +"""Orchestration vocale — catalogue GLOBAL + déploiement par tablette. -Compose : provider (`/voices` → wav_path), transfert adb, bridge (worker ov_venv), -store chiffré. Tourne sous l'API py3.14 ; le calcul lourd est délégué au worker. +Modèle (refonte 23/06/2026) : une voix est un **asset de flotte** (catalogue central, +clé `voice_id`), PAS liée à la tablette d'enregistrement. La tablette n'est qu'une +**cible de déploiement** ; l'artefact `.ovsp` est stocké chiffré côté central et +poussé sur N tablettes sans ré-enrôler. -Workflow (VOICE_ENROLLMENT_SPEC §1, décisions 2026-06-19) : - connexion → lister voix sans .ovsp → pull WAV → ARCHIVER (store chiffré) → - transcrire (ASR OmniVoice, relecture opérateur) → enrôler (.ovsp) → pousser sur la - tablette → SUPPRIMER le WAV de la tablette (gardé sur Kazeia-central). - -Découpé pour permettre la relecture de la transcription : `prepare` (pull+archive+ -transcribe) puis `enroll_deploy` (enroll+push+delete). Le WAV est archivé DÈS prepare -→ jamais de suppression device sans copie PC. +Source des voix fraîches : stockage ADMIN (`admin_ingest`, manifestes scope/owner/ +reference_text). Politique : `global` → déployable partout ; `exclusive` → seulement +sur une tablette portant le profil propriétaire ; `pending` → archivée, pas déployable. +Le `.ovsp` porte l'ASR du segment 16 s (§6) ; le consentement reste en métadonnée. """ from __future__ import annotations @@ -26,169 +24,6 @@ from ..store import Store from . import admin_ingest, transfer from .bridge import VoiceBridge - -def _provider_voices(adb: Adb, serial: str): - return ProviderClient(adb, serial).voices() - - -def _find_wav_path(adb: Adb, serial: str, voice_id: str) -> str | None: - for v in _provider_voices(adb, serial): - if v.id == voice_id: - return v.wav_path - return None - - -def _status(deployed: bool, rec: dict) -> str: - if deployed: - return "deployed" - if rec.get("enrolled_at"): - return "enrolled" - if rec.get("archived_at"): - return "archived" - 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]]: - """Inventaire voix de la tablette croisé avec l'archive store (statut, verrou, - profils utilisateurs) par voix.""" - rows = _provider_voices(adb, serial) - try: - deployed = transfer.deployed_ovsp(adb, serial, _patient_omnivoice_dir(adb, serial)) - except Exception: - deployed = set() - recs = {r["voice_id"]: r for r in store.voices(serial)} if store.is_unlocked else {} - used_by, _ = _profiles_using(adb, serial) - out = [] - for v in rows: - rec = recs.get(v.id, {}) - is_dep = v.id in deployed - locked = rec.get("locked_profile_id") - out.append({ - "voice_id": v.id, - "wav_path": v.wav_path, - "wav_on_device": v.wav_exists, - "deployed_on_device": is_dep, - "archived": bool(rec.get("archived_at")), - "enrolled": bool(rec.get("enrolled_at")), - "wav_deleted": bool(rec.get("wav_deleted_at")), - "transcription": rec.get("transcription"), - "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), - }) - 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, *, - now: int) -> bytes: - """Garantit que le WAV est archivé (chiffré) côté PC ; le renvoie déchiffré.""" - data = store.voice_wav_bytes(serial, voice_id) - if data is not None: - return data - wav_path = _find_wav_path(adb, serial, voice_id) - if not wav_path: - raise ValueError(f"voix introuvable sur la tablette: {voice_id}") - with tempfile.TemporaryDirectory() as td: - local = os.path.join(td, f"{voice_id}.wav") - transfer.pull_wav(adb, serial, wav_path, local) - data = open(local, "rb").read() - store.archive_voice_wav(serial, voice_id, data, source_wav_path=wav_path, now=now) - return data - - -def prepare(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id: str, *, - now: int, actor: str | None = None) -> dict[str, Any]: - """Pull + archive (chiffré) + transcription (ASR OmniVoice). Renvoie le texte à relire.""" - data = _ensure_wav_archived(adb, store, serial, voice_id, now=now) - with tempfile.TemporaryDirectory() as td: - local = os.path.join(td, f"{voice_id}.wav") - open(local, "wb").write(data) - tr = bridge.transcribe(local) - store.set_voice_transcription(serial, voice_id, tr["text"], tr.get("language"), now=now) - store.audit("voice_prepare", actor=actor, target=f"{serial}/{voice_id}", now=now) - return {"voice_id": voice_id, "transcription": tr["text"], - "language": tr.get("language"), "model": tr.get("model")} - - -def enroll_deploy(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id: str, - transcription: str, *, delete_source: bool, now: int, - actor: str | None = None) -> dict[str, Any]: - """Enrôle (.ovsp) → pousse sur la tablette → (option) supprime le WAV device. - Le WAV reste archivé côté PC → suppression device réversible par ré-enrôlement.""" - data = _ensure_wav_archived(adb, store, serial, voice_id, now=now) - wav_path = _find_wav_path(adb, serial, voice_id) - ov = _patient_omnivoice_dir(adb, serial) - - with tempfile.TemporaryDirectory() as td: - local = os.path.join(td, f"{voice_id}.wav") - open(local, "wb").write(data) - ovsp = os.path.join(td, f"{voice_id}.ovsp") - info = bridge.enroll(local, transcription, ovsp) - store.set_voice_transcription(serial, voice_id, transcription, None, now=now) - store.mark_voice_enrolled(serial, voice_id, info["bytes"], now=now) - remote = transfer.push_ovsp(adb, serial, ovsp, ov, voice_id) - store.mark_voice_deployed(serial, voice_id, now=now) - store.audit("voice_enroll_deploy", actor=actor, target=f"{serial}/{voice_id}", - detail={"ovsp_bytes": info["bytes"]}, now=now) - - result = {"voice_id": voice_id, "ovsp_bytes": info["bytes"], - "deployed_to": remote, "wav_deleted": False} - if delete_source and wav_path: - transfer.delete_device_wav(adb, serial, wav_path) - store.mark_voice_wav_deleted(serial, voice_id, now=now) - store.audit("voice_wav_deleted", actor=actor, target=f"{serial}/{voice_id}", now=now) - result["wav_deleted"] = True - return result - - -# ===== Ingestion depuis le stockage ADMIN (manifestes record-time, §4.2) ===== -# Source de vérité des voix fraîches : `Android/data/com.kazeia.admin/files/voix/` -# (scope/owner/reference_text ensemble), pas le provider patient `/voices`. - # MODELS_DIR candidats côté patient (dev legacy / prod scoped-storage). _CANDIDATE_MODELS_DIRS = ( "/data/local/tmp/kazeia/models", @@ -196,11 +31,20 @@ _CANDIDATE_MODELS_DIRS = ( ) +def _provider_voices(adb: Adb, serial: str): + return ProviderClient(adb, serial).voices() + + +def _profiles_present(adb: Adb, serial: str) -> set[str]: + try: + return {p.id for p in ProviderClient(adb, serial).profiles()} + except Exception: + return set() + + def _patient_omnivoice_dir(adb: Adb, serial: str) -> str: - """Dossier `.ovsp` cible côté patient (`/omnivoice/voices`). Les - manifestes admin ne le donnent pas, et post-migration `/voices.wav_path` est VIDE - pour une voix déployée → on dérive en cascade : override env → wav_path legacy - (marqueur MODELS_DIR) → sonde des dossiers modèles connus sur le device.""" + """Dossier `.ovsp` cible (`/omnivoice/voices`). Post-migration + `/voices.wav_path` est VIDE → cascade : env → wav_path legacy → sonde device.""" env = os.environ.get("KAZEIA_OMNIVOICE_DIR") if env: return env @@ -210,14 +54,109 @@ def _patient_omnivoice_dir(adb: Adb, serial: str) -> str: for base in _CANDIDATE_MODELS_DIRS: if base in adb.shell(f"ls -d {base} 2>/dev/null", serial=serial): return posixpath.join(base, "omnivoice", "voices") - raise ValueError( - "dossier omnivoice indéterminable (ni /voices wav_path, ni dossier modèles connu ; " - "définir KAZEIA_OMNIVOICE_DIR)") + raise ValueError("dossier omnivoice indéterminable (définir KAZEIA_OMNIVOICE_DIR)") +def _deployable(rec: dict, present: set[str]) -> tuple[bool, str | None]: + """Une voix du catalogue est-elle déployable sur une tablette ? `global` partout ; + `exclusive` seulement si le profil propriétaire est présent ; `pending` jamais.""" + if rec.get("scope") == "pending": + return False, "pending" + lock = rec.get("locked_profile_id") + if lock and lock not in present: + return False, "verrou_profil_absent" + return True, None + + +# ===== Catalogue global ===================================================== +def catalog(store: Store) -> list[dict[str, Any]]: + """Catalogue voix GLOBAL (toutes tablettes confondues) — vue sans sélection.""" + return store.voices() if store.is_unlocked else [] + + +# ===== Enrôlement (asset central) =========================================== +def _enroll_into_store(store: Store, bridge: VoiceBridge, voice_id: str, *, + now: int, text: str = "") -> dict: + """Enrôle depuis le WAV archivé → stocke le `.ovsp` chiffré au catalogue. + `text` vide → ASR auto du segment 16 s (§6).""" + data = store.voice_wav_bytes(voice_id) + if data is None: + raise ValueError(f"WAV non archivé pour {voice_id}") + with tempfile.TemporaryDirectory() as td: + local = os.path.join(td, f"{voice_id}.wav") + open(local, "wb").write(data) + ovsp = os.path.join(td, f"{voice_id}.ovsp") + info = bridge.enroll(local, text, ovsp) + store.set_voice_transcription(voice_id, info.get("ref_text", ""), now=now) + store.store_voice_ovsp(voice_id, open(ovsp, "rb").read(), now=now) + return info + + +# ===== Déploiement par tablette ============================================= +def deploy_voice(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voice_id: str, *, + now: int, actor: str | None = None) -> dict[str, Any]: + """Déploie une voix du catalogue sur une tablette : enrôle si besoin (1ʳᵉ fois), + pousse le `.ovsp` stocké, enregistre le déploiement. Respecte l'exclusivité.""" + rec = store.voice_record(voice_id) + if not rec: + raise ValueError(f"voix inconnue au catalogue: {voice_id}") + ok, reason = _deployable(rec, _profiles_present(adb, serial)) + if not ok: + return {"voice_id": voice_id, "deployed": False, "reason": reason} + if not store.voice_ovsp_bytes(voice_id): + _enroll_into_store(store, bridge, voice_id, now=now) + ovsp_bytes = store.voice_ovsp_bytes(voice_id) + ov = _patient_omnivoice_dir(adb, serial) + with tempfile.TemporaryDirectory() as td: + local = os.path.join(td, f"{voice_id}.ovsp") + open(local, "wb").write(ovsp_bytes) + remote = transfer.push_ovsp(adb, serial, local, ov, voice_id) + store.add_deployment(voice_id, serial, now=now) + store.audit("voice_deploy", actor=actor, target=f"{serial}/{voice_id}", now=now) + return {"voice_id": voice_id, "deployed": True, "deployed_to": remote} + + +def undeploy_voice(adb: Adb, store: Store, serial: str, voice_id: str, *, + now: int, actor: str | None = None) -> dict[str, Any]: + """Retire le `.ovsp` d'une tablette + ôte le déploiement (la voix reste au catalogue).""" + ov = _patient_omnivoice_dir(adb, serial) + adb.shell(f"rm -f {posixpath.join(ov, voice_id + '.ovsp')}", serial=serial) + store.remove_deployment(voice_id, serial) + store.audit("voice_undeploy", actor=actor, target=f"{serial}/{voice_id}", now=now) + return {"voice_id": voice_id, "deployed": False} + + +def list_for_tablet(adb: Adb, store: Store, serial: str) -> dict[str, Any]: + """Vue d'une tablette : voix **chargées** (sur le device) et voix **chargeables** + (catalogue non déployé, filtré par exclusivité).""" + try: + on_device = transfer.deployed_ovsp(adb, serial, _patient_omnivoice_dir(adb, serial)) + except Exception: + on_device = set() + present = _profiles_present(adb, serial) + cat = store.voices() if store.is_unlocked else [] + cat_ids = {r["voice_id"] for r in cat} + deployed, deployable = [], [] + for rec in cat: + vid = rec["voice_id"] + base = {"voice_id": vid, "name": rec.get("name"), "scope": rec.get("scope"), + "locked_profile_id": rec.get("locked_profile_id"), "owner_name": rec.get("owner_name")} + if vid in on_device or serial in rec.get("deployed_serials", []): + deployed.append({**base, "on_device": vid in on_device, "in_catalog": True}) + else: + ok, reason = _deployable(rec, present) + deployable.append({**base, "deployable": ok, "reason": reason}) + # Voix présentes sur le device mais hors catalogue (legacy/externe). + for vid in sorted(on_device - cat_ids): + deployed.append({"voice_id": vid, "name": None, "scope": None, "on_device": True, + "in_catalog": False}) + return {"serial": serial, "deployed": deployed, "deployable": deployable} + + +# ===== Ingestion depuis le stockage ADMIN (manifestes record-time, §4.2) ===== def _admin_status(man: dict, rec: dict, deployed: bool) -> str: if man["scope"] == "pending": - return "pending" # ni enrôlée ni déployable tant que non résolue + return "pending" if deployed: return "deployed" if rec.get("enrolled_at"): @@ -228,10 +167,9 @@ def _admin_status(man: dict, rec: dict, deployed: bool) -> str: def list_admin_voices(adb: Adb, store: Store, serial: str) -> list[dict[str, Any]]: - """Voix enregistrées via l'admin (manifestes) croisées avec l'archive store et - l'état de déploiement. C'est la liste à piloter depuis l'UI.""" + """Voix enregistrées via l'admin (manifestes) croisées avec le catalogue global.""" manifests = admin_ingest.list_manifests(adb, serial) - 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()} if store.is_unlocked else {} try: deployed = transfer.deployed_ovsp(adb, serial, _patient_omnivoice_dir(adb, serial)) except Exception: @@ -252,64 +190,39 @@ def list_admin_voices(adb: Adb, store: Store, serial: str) -> list[dict[str, Any def ingest_voice(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, manifest: dict, *, delete_source: bool, now: int, actor: str | None = None) -> dict[str, Any]: - """Ingère UNE voix admin : pull WAV → archive chiffrée → métadonnées (scope/owner/ - consentement) → verrou si exclusive → enrôle (ASR du 16 s, §6) → déploie selon scope. - `pending` = archivée mais NI enrôlée NI déployée (cadre à fixer à l'attribution).""" + """Ingère UNE voix admin au **catalogue global** : pull WAV → archive chiffrée + (origin=cette tablette) → métadonnées (scope/owner/consentement) → verrou si + exclusive → enrôle + déploie sur la tablette d'origine (selon scope).""" vid = manifest["id"] scope = manifest["scope"] owner = manifest.get("owner_profile_id") - # 1) Archiver le WAV (depuis le stockage admin) s'il ne l'est pas déjà. - data = store.voice_wav_bytes(serial, vid) - if data is None: + if store.voice_wav_bytes(vid) is None: with tempfile.TemporaryDirectory() as td: local = os.path.join(td, f"{vid}.wav") transfer.pull_wav(adb, serial, manifest["wav_device_path"], local) data = open(local, "rb").read() - store.archive_voice_wav(serial, vid, data, + store.archive_voice_wav(vid, data, origin_serial=serial, name=manifest.get("name"), source_wav_path=manifest["wav_device_path"], now=now) - - # 2) Métadonnées manifeste (scope/propriétaire/consentement chiffré) + verrou. - store.set_voice_manifest(serial, vid, scope=scope, owner_name=manifest.get("owner_name"), + store.set_voice_manifest(vid, scope=scope, owner_name=manifest.get("owner_name"), consent_text=manifest.get("reference_text"), now=now) if scope == "exclusive" and owner: - store.lock_voice(serial, vid, owner, now=now) + store.lock_voice(vid, owner, now=now) store.audit("voice_admin_ingest", actor=actor, target=f"{serial}/{vid}", detail={"scope": scope, "owner": owner}, now=now) - # 3) pending → on s'arrête (archivée, à résoudre plus tard). if scope == "pending": return {"voice_id": vid, "scope": scope, "deployed": False, "reason": "pending"} - # 4) exclusive → le profil propriétaire doit être présent sur la tablette. - if scope == "exclusive" and owner: - _, present = _profiles_using(adb, serial) - if owner not in present: - return {"voice_id": vid, "scope": scope, "deployed": False, - "reason": "verrou_profil_absent", "profile": owner} - - # 5) Enrôler (texte vide → ASR auto du segment 16 s, §6) + déployer sur la tablette. - ov = _patient_omnivoice_dir(adb, serial) - with tempfile.TemporaryDirectory() as td: - local = os.path.join(td, f"{vid}.wav") - open(local, "wb").write(data) - ovsp = os.path.join(td, f"{vid}.ovsp") - info = bridge.enroll(local, "", ovsp) - store.set_voice_transcription(serial, vid, info.get("ref_text", ""), None, now=now) - store.mark_voice_enrolled(serial, vid, info["bytes"], now=now) - remote = transfer.push_ovsp(adb, serial, ovsp, ov, vid) - store.mark_voice_deployed(serial, vid, now=now) - store.audit("voice_enroll_deploy", actor=actor, target=f"{serial}/{vid}", - detail={"ovsp_bytes": info["bytes"], "scope": scope}, now=now) - - result = {"voice_id": vid, "scope": scope, "deployed": True, "deployed_to": remote, - "ovsp_bytes": info["bytes"], "wav_deleted": False} - if delete_source: + res = deploy_voice(adb, store, bridge, serial, vid, now=now, actor=actor) + out = {"voice_id": vid, "scope": scope, "deployed": res.get("deployed", False), + "reason": res.get("reason"), "deployed_to": res.get("deployed_to"), "wav_deleted": False} + if delete_source and res.get("deployed"): admin_ingest.delete_admin_voice(adb, serial, manifest) - store.mark_voice_wav_deleted(serial, vid, now=now) + store.mark_voice_wav_deleted(vid, now=now) store.audit("voice_wav_deleted", actor=actor, target=f"{serial}/{vid}", now=now) - result["wav_deleted"] = True - return result + out["wav_deleted"] = True + return out def sync_admin(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, *, diff --git a/kazeia_central/web/index.html b/kazeia_central/web/index.html index 8bcffbd..8bd90e0 100644 --- a/kazeia_central/web/index.html +++ b/kazeia_central/web/index.html @@ -260,25 +260,41 @@ async function selectDevice(serial) { d.innerHTML = `
${state}${updates}${rag}${profiles}${convs}${crashes}
`; } -// ---- panneau voix (enrôlement OmniVoice) --------------------------------- +// ---- panneau voix : catalogue GLOBAL (flotte) + déploiement par tablette ---- const SCOPE_COLOR = { exclusive:"var(--warn)", global:"var(--ok)", pending:"var(--muted)" }; function scopeBadge(s) { return `${s||"?"}`; } async function voiceView() { - if (!selected) { $("detail").innerHTML = `
← sélectionne d'abord une tablette
`; return; } if (!unlocked) { $("detail").innerHTML = `

Voix

Déverrouille le store (colonne de gauche) pour gérer les voix.
`; return; } - const serial = selected; + const serial = selected; // peut être null → vue catalogue seule const d = $("detail"); - d.innerHTML = `
chargement des voix de ${serial}
`; - const [auto, admin, deployed] = await Promise.all([ - api("/voices/autosync").catch(e=>({error:e.message})), - api(`/voices/${serial}/admin`).catch(e=>({error:e.message})), - api(`/voices/${serial}`).catch(e=>({error:e.message})), - ]); + d.innerHTML = `
chargement du catalogue voix…
`; + const reqs = [api("/voices/catalog").catch(e=>({error:e.message})), + api("/voices/autosync").catch(e=>({error:e.message}))]; + if (serial) reqs.push(api(`/voices/${serial}`).catch(e=>({error:e.message})), + api(`/voices/${serial}/admin`).catch(e=>({error:e.message}))); + const [cat, auto, tablet, admin] = await Promise.all(reqs); - // 1) déclencheur auto + // 1) catalogue global (toutes tablettes) — visible SANS sélectionner de tablette + const cv = Array.isArray(cat) ? cat : []; + const catCard = card(`Catalogue voix — flotte (${cv.length})`, + cat.error ? `
${cat.error}
` : + (cv.length ? ` + + ${cv.map(v=>` + + + + + + `).join("")} +
voixportéeverroudéployée sur
${v.name||v.voice_id}
${v.voice_id}
${scopeBadge(v.scope)}${v.locked_profile_id?`🔒 ${v.owner_name||v.locked_profile_id}`:"généraliste"}${(v.deployed_serials||[]).length?(v.deployed_serials).join(", "):"—"}${v.locked_profile_id + ? `` + : ``}
` : `catalogue vide — enregistre des voix via l'app admin puis synchronise`), "full"); + + // 2) déclencheur auto const aStat = auto?.devices?.[serial]; const autoCard = card("Déclencheur automatique", auto.error ? `
${auto.error}
` : @@ -286,48 +302,41 @@ async function voiceView() {
-
-
À l'armement, toute tablette qui se (re)connecte est enrôlée automatiquement. - ${aStat?`
Dernière sync ${serial} : ${aStat.state}${aStat.enrolled!=null?` (${aStat.enrolled}/${aStat.total})`:""}`:""}
`); + ${aStat?`
Dernière sync ${serial} : ${aStat.state}${aStat.enrolled!=null?` (${aStat.enrolled}/${aStat.total})`:""}
`:""}`); - // 2) voix admin à enrôler - const av = Array.isArray(admin) ? admin : []; - const adminCard = card(`Voix enregistrées — admin (${av.length})`, - admin.error ? `
${admin.error}
` : - (av.length ? ` - - ${av.map(v=>` - - - - - - `).join("")} -
nomportéepropriétairestatutconsentement
${v.name||v.voice_id}
${v.voice_id}
${scopeBadge(v.scope)}${v.owner_name||v.owner_profile_id||"—"}${v.status}${v.deployed_on_device?" ✓":""}${(v.reference_text||"—").slice(0,40)}${(v.reference_text||"").length>40?"…":""}
` : `aucune voix enregistrée côté admin`) + - `
- - - -
`, "full"); + let cards = catCard + autoCard; - // 3) voix déployées (verrou) - const dv = Array.isArray(deployed) ? deployed : []; - const depCard = card(`Voix déployées sur la tablette (${dv.filter(v=>v.deployed_on_device).length})`, - deployed.error ? `
${deployed.error}
` : - (dv.length ? ` - - ${dv.map(v=>` - - - - - - `).join("")} -
voixstatutverrouprofils
${v.voice_id}${v.deployed_on_device?dot(true)+"déployée":v.status}${v.locked_profile_id?`🔒 ${v.locked_profile_id}`:"généraliste"}${(v.used_by_profiles||[]).join(", ")||"—"}${v.locked_profile_id - ? `` - : ``}
` : `aucune voix`), "full"); + if (!serial) { + cards += card("Par tablette", `Sélectionne une tablette (colonne de gauche), puis reclique 🎙 Voix : tu verras ce qui y est chargé et les voix chargeables dessus.`, "full"); + } else { + // 3) voix admin à enrôler sur cette tablette + const av = Array.isArray(admin) ? admin : []; + const adminCard = card(`Voix admin à enrôler — ${serial} (${av.length})`, + admin.error ? `
${admin.error}
` : + (av.length ? ` + ${av.map(v=>``).join("")} +
nomportéepropriétairestatut
${v.name||v.voice_id}${scopeBadge(v.scope)}${v.owner_name||v.owner_profile_id||"—"}${v.status}
` : `aucune voix admin en attente`) + + `
+ + +
`, "full"); - d.innerHTML = `
${autoCard}${adminCard}${depCard}
`; + // 4) tablette : voix chargées + chargeables + const loaded = (tablet && tablet.deployed) || [], loadable = (tablet && tablet.deployable) || []; + const tabCard = card(`Tablette ${serial} — chargées (${loaded.length}) · chargeables (${loadable.filter(v=>v.deployable).length})`, + tablet.error ? `
${tablet.error}
` : + ` + ${loaded.map(v=>` + + `).join("")} + ${loadable.map(v=>` + + `).join("")} +
voixportéeétat
${dot(true)}${v.name||v.voice_id}${v.in_catalog===false?" (hors catalogue)":""}${v.scope?scopeBadge(v.scope):"—"}chargée${v.in_catalog!==false?``:""}
${v.name||v.voice_id}${scopeBadge(v.scope)}${v.deployable?"chargeable":(v.reason||"—")}${v.deployable?``:""}
`, "full"); + + cards += adminCard + tabCard; + } + d.innerHTML = `
${cards}
`; } async function toggleAutosync(on) { @@ -344,22 +353,39 @@ async function syncAdmin(serial) { try { const rep = await api(`/voices/${serial}/admin/sync`, {method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({delete_source:del})}); - const ok = rep.filter(r=>r.ok && r.deployed).length; - msg.textContent = `${ok}/${rep.length} voix déployée(s).`; + msg.textContent = `${rep.filter(r=>r.ok && r.deployed).length}/${rep.length} voix déployée(s).`; await voiceView(); } catch (e) { msg.textContent = "erreur : " + e.message; btn.disabled = false; } } -async function lockVoice(serial, vid) { +async function deployVoice(serial, vid) { + const m = document.createElement("div"); m.className = "muted"; + m.textContent = `déploiement de ${vid} sur ${serial}… (1ʳᵉ fois : enrôlement ~1 min)`; + $("detail").prepend(m); + try { + const r = await api(`/voices/${serial}/deploy`, {method:"POST", + headers:{"content-type":"application/json"}, body: JSON.stringify({voice_id:vid})}); + if (!r.deployed) alert("non chargée : " + (r.reason || "")); + await voiceView(); + } catch (e) { alert("erreur : " + e.message); } +} + +async function undeployVoice(serial, vid) { + await api(`/voices/${serial}/undeploy`, {method:"POST", + headers:{"content-type":"application/json"}, body: JSON.stringify({voice_id:vid})}); + voiceView(); +} + +async function lockVoice(vid) { const pid = prompt(`Verrouiller « ${vid} » sur quel profil patient ? (profile_id)`); if (!pid) return; - await api(`/voices/${serial}/${vid}/lock`, {method:"POST", + await api(`/voices/catalog/${vid}/lock`, {method:"POST", headers:{"content-type":"application/json"}, body: JSON.stringify({profile_id:pid})}); voiceView(); } -async function unlockVoice(serial, vid) { - await api(`/voices/${serial}/${vid}/unlock`, {method:"POST"}); +async function unlockVoice(vid) { + await api(`/voices/catalog/${vid}/unlock`, {method:"POST"}); voiceView(); } diff --git a/tests/test_voice.py b/tests/test_voice.py index 3b91caf..1c61fa0 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -1,20 +1,28 @@ -"""Tests du sous-système voix : dérivation chemins, archive store, orchestration. +"""Tests du sous-système voix — modèle GLOBAL (catalogue flotte + déploiement). -Le moteur lourd (OmniVoice) n'est PAS testé ici (validé live) — on injecte un faux -bridge et un faux adb pour prouver la COMPOSITION (workflow) sans le modèle. +Le moteur lourd (OmniVoice) n'est PAS testé ici (validé live) — faux bridge + faux adb +pour prouver la COMPOSITION (catalogue, déploiement scope-conscient, ingestion). """ +import json as _json +import os as _os +import tempfile + import nacl.pwhash from kazeia_central.adb import Adb from kazeia_central.adb.client import AdbDevice from kazeia_central.store import Store +from kazeia_central.voice import admin_ingest from kazeia_central.voice import orchestrator as vo from kazeia_central.voice import transfer _OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN _MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN NOW = 1_700_000_000_000 +_ADMIN = admin_ingest.ADMIN_VOIX_DIR +_PWP = "/data/local/tmp/kazeia/models/../voix/voix/x.wav" +_OVDIR = "/data/local/tmp/kazeia/models/omnivoice/voices" def _store(tmp_path): @@ -23,208 +31,10 @@ def _store(tmp_path): return s -# ---- dérivation des chemins device --------------------------------------- -def test_omnivoice_dir_marker(): - wp = "/data/local/tmp/kazeia/models/../voix/voix/amir.wav" - assert transfer.omnivoice_dir_for(wp) == "/data/local/tmp/kazeia/models/omnivoice/voices" - - -def test_omnivoice_dir_fallback_prod(): - wp = "/sdcard/Android/data/com.kazeia/files/kazeia/voix/voix/amir.wav" - assert transfer.omnivoice_dir_for(wp) == \ - "/sdcard/Android/data/com.kazeia/files/kazeia/models/omnivoice/voices" - - -# ---- archive voix dans le store ------------------------------------------ -def test_voice_wav_archive_roundtrip_and_encrypted(tmp_path): - s = _store(tmp_path) - s.archive_voice_wav("D1", "amir", b"FAKEWAVDATA-amir", source_wav_path="/dev/amir.wav", now=NOW) - assert s.voice_wav_bytes("D1", "amir") == b"FAKEWAVDATA-amir" - # chiffré au repos : le blob brut ne contient pas le contenu - enc = (tmp_path / "voices" / "D1" / "amir.wav.enc").read_bytes() - assert b"FAKEWAVDATA" not in enc - rec = s.voice_record("D1", "amir") - assert rec["has_wav"] and rec["wav_sha256"] and rec["archived_at"] == NOW - - -def test_voice_transcription_encrypted(tmp_path): - s = _store(tmp_path) - s.archive_voice_wav("D1", "amir", b"x", source_wav_path=None, now=NOW) - s.set_voice_transcription("D1", "amir", "texte parlé confidentiel", "fr", now=NOW) - assert s.voice_record("D1", "amir")["transcription"] == "texte parlé confidentiel" - raw = (tmp_path / "c.db").read_bytes() - assert b"texte parl" not in raw # PII jamais en clair en base - - -def test_voice_status_marks(tmp_path): - s = _store(tmp_path) - s.archive_voice_wav("D1", "amir", b"x", source_wav_path=None, now=NOW) - s.mark_voice_enrolled("D1", "amir", 242000, now=NOW) - s.mark_voice_deployed("D1", "amir", now=NOW) - r = s.voice_record("D1", "amir") - assert r["ovsp_bytes"] == 242000 and r["enrolled_at"] and r["deployed_at"] - - -# ---- orchestration (faux adb + faux bridge) ------------------------------ -class _VoiceAdb(Adb): - def __init__(self, voices, deployed=(), profiles=()): - super().__init__() - self._voices = voices # [(id, wav_path, wav_exists)] - self._deployed = list(deployed) - self._profiles = list(profiles) # [(profile_id, voice_id)] - self.pushed = [] - self.deleted = [] - - def ready_devices(self): - return [AdbDevice("D1", "device", {})] - - def content_query(self, path, *, serial=None, where=None, expect_rows=False, _retry=True): - if path == "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 [] - - def shell(self, command, *, serial=None, timeout=None): - if command.startswith("ls "): - return "\n".join(f"{d}.ovsp" for d in self._deployed) - if "rm -f" in command: - self.deleted.append(command.split("rm -f ")[1].split(" &&")[0]) - return "OK" - return "" - - def pull(self, remote, local, *, serial=None): - open(local, "wb").write(b"WAV:" + remote.encode()) - - def push(self, local, remote, *, serial=None): - self.pushed.append((open(local, "rb").read(), remote)) - - -class _FakeBridge: - def transcribe(self, wav, model=None): - return {"text": "bonjour ceci est ma voix", "language": "fr", "model": "small"} - - def enroll(self, wav, text, out): - open(out, "wb").write(b"OVSP" + text.encode()[:5]) - return {"bytes": 242000, "t_ref": 364, "ref_seconds": 14.6, "text_bytes": len(text)} - - -_WP = "/data/local/tmp/kazeia/models/../voix/voix/amir.wav" - - -def test_list_voices_status(tmp_path): - s = _store(tmp_path) - adb = _VoiceAdb([("amir", _WP, 1), ("damien", - "/data/local/tmp/kazeia/models/../voix/voix/damien.wav", 1)], - deployed=["damien"]) - out = {v["voice_id"]: v for v in vo.list_voices(adb, s, "D1")} - assert out["damien"]["status"] == "deployed" - assert out["amir"]["status"] == "pending" - - -def test_prepare_archives_and_transcribes(tmp_path): - s = _store(tmp_path) - adb = _VoiceAdb([("amir", _WP, 1)]) - res = vo.prepare(adb, s, _FakeBridge(), "D1", "amir", now=NOW) - assert res["transcription"].startswith("bonjour") - # WAV archivé (chiffré) + transcription posée - assert s.voice_wav_bytes("D1", "amir") == b"WAV:" + _WP.encode() - assert s.voice_record("D1", "amir")["transcription"].startswith("bonjour") - - -def test_enroll_deploy_pushes_and_optional_delete(tmp_path): - s = _store(tmp_path) - adb = _VoiceAdb([("amir", _WP, 1)]) - # sans suppression - r = vo.enroll_deploy(adb, s, _FakeBridge(), "D1", "amir", "ma voix", - delete_source=False, now=NOW) - assert r["ovsp_bytes"] == 242000 and r["wav_deleted"] is False - assert adb.pushed and adb.pushed[0][1] == \ - "/data/local/tmp/kazeia/models/omnivoice/voices/amir.ovsp" - assert s.voice_record("D1", "amir")["deployed_at"] == NOW - assert adb.deleted == [] - # avec suppression - r2 = vo.enroll_deploy(adb, s, _FakeBridge(), "D1", "amir", "ma voix", - delete_source=True, now=NOW) - assert r2["wav_deleted"] is True and adb.deleted == [_WP] - 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): - # autosync ingère depuis le stockage admin (manifestes) → adb admin + bridge ASR. - from kazeia_central.voice.autosync import VoiceAutoSync - s = _store(tmp_path) - a = VoiceAutoSync(_AdminAdb([_manifest("paul", "global")]), s, _BridgeASR()) - 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 - - -# ---- ingestion depuis le stockage ADMIN (manifestes record-time, §4.2) ---- -import json as _json -import os as _os - -from kazeia_central.voice import admin_ingest - -_ADMIN = admin_ingest.ADMIN_VOIX_DIR -_PWP = "/data/local/tmp/kazeia/models/../voix/voix/x.wav" -_OVDIR = "/data/local/tmp/kazeia/models/omnivoice/voices" - - def _manifest(vid, scope, owner=None): m = {"id": vid, "name": vid.title(), "state": "recorded", "wav": f"{vid}.wav", "duration_seconds": 16.0, "created_at": 1, - "reference_text": f"Phrase de consentement de {vid} — SECRET-{vid}", + "reference_text": f"Consentement de {vid} — SECRET-{vid}", "source_device": "tablet-admin-recording", "scope": scope} if owner: m["owner_profile_id"] = owner @@ -232,8 +42,9 @@ def _manifest(vid, scope, owner=None): return m -class _AdminAdb(Adb): - def __init__(self, manifests, profiles=(), deployed=()): +class _Adb(Adb): + """Faux adb : manifestes admin, profils, dossier omnivoice (déployés), push/rm.""" + def __init__(self, manifests=(), profiles=(), deployed=()): super().__init__() self._manifests = {m["id"]: m for m in manifests} self._profiles = list(profiles) @@ -254,7 +65,7 @@ class _AdminAdb(Adb): def shell(self, command, *, serial=None, timeout=None): if command.startswith(f"ls {_ADMIN}"): return "\n".join(f"{i}.json" for i in self._manifests) - if command.startswith("ls "): # dossier omnivoice (déployés) + if command.startswith("ls "): return "\n".join(f"{d}.ovsp" for d in self._deployed) if "rm -f" in command: self.removed.append(command.split("rm -f ")[1].split(" ")[0]) @@ -272,76 +83,159 @@ class _AdminAdb(Adb): self.pushed.append((open(local, "rb").read(), remote)) -class _BridgeASR: +class _Bridge: def enroll(self, wav, text, out): - open(out, "wb").write(b"OVSP-asr") - return {"bytes": 1234, "t_ref": 62, "ref_text": "transcription asr du segment"} + open(out, "wb").write(b"OVSP-bytes") + return {"bytes": 9, "t_ref": 62, "ref_text": "asr du segment"} -def test_admin_list_manifests_parse(): - adb = _AdminAdb([_manifest("marie", "exclusive", owner="p1"), _manifest("paul", "global")]) - out = {v["id"]: v for v in admin_ingest.list_manifests(adb, "D1")} - assert out["marie"]["scope"] == "exclusive" and out["marie"]["owner_profile_id"] == "p1" - assert out["marie"]["wav_device_path"] == _os.path.join(_ADMIN, "marie.wav") - assert out["paul"]["scope"] == "global" and out["paul"]["owner_profile_id"] is None +# ---- chemins ------------------------------------------------------------- +def test_omnivoice_dir_marker(): + assert transfer.omnivoice_dir_for("/data/local/tmp/kazeia/models/../voix/voix/a.wav") \ + == "/data/local/tmp/kazeia/models/omnivoice/voices" -def test_set_voice_manifest_consent_encrypted(tmp_path): +# ---- store : catalogue global + déploiements ----------------------------- +def test_store_global_catalog_and_deployments(tmp_path): s = _store(tmp_path) - s.set_voice_manifest("D1", "marie", scope="exclusive", owner_name="Patient p1", - consent_text="Phrase SECRET-marie", now=NOW) - rec = s.voice_record("D1", "marie") - assert rec["scope"] == "exclusive" and rec["consent_text"] == "Phrase SECRET-marie" + s.archive_voice_wav("marie_1", b"WAV", origin_serial="DA", source_wav_path="/x", name="Marie", now=NOW) + s.store_voice_ovsp("marie_1", b"OVSP", now=NOW) + s.lock_voice("marie_1", "p1", now=NOW) + assert s.voice_wav_bytes("marie_1") == b"WAV" and s.voice_ovsp_bytes("marie_1") == b"OVSP" + s.add_deployment("marie_1", "DA", now=NOW) + s.add_deployment("marie_1", "DB", now=NOW) + rec = s.voice_record("marie_1") + assert rec["origin_serial"] == "DA" and rec["locked_profile_id"] == "p1" + assert rec["scope"] == "exclusive" and sorted(rec["deployed_serials"]) == ["DA", "DB"] + assert s.voices_on("DB") == {"marie_1"} + s.remove_deployment("marie_1", "DB") + assert s.deployments_of("marie_1") == ["DA"] + + +def test_store_ovsp_encrypted_at_rest(tmp_path): + s = _store(tmp_path) + s.archive_voice_wav("v", b"x", origin_serial="DA", source_wav_path=None, now=NOW) + s.set_voice_manifest("v", scope="exclusive", owner_name="P", consent_text="SECRET-consent", now=NOW) raw = (tmp_path / "c.db").read_bytes() - assert b"SECRET-marie" not in raw # consentement (PII) chiffré au repos + assert b"SECRET-consent" not in raw # consentement chiffré -def test_ingest_pending_archives_no_deploy(tmp_path): +# ---- politique de déployabilité ------------------------------------------ +def test_deployable_policy(): + assert vo._deployable({"scope": "global"}, set()) == (True, None) + assert vo._deployable({"scope": "pending"}, {"p1"}) == (False, "pending") + assert vo._deployable({"scope": "exclusive", "locked_profile_id": "p1"}, {"p1"}) == (True, None) + ok, reason = vo._deployable({"scope": "exclusive", "locked_profile_id": "p1"}, {"p2"}) + assert ok is False and reason == "verrou_profil_absent" + + +# ---- déploiement par tablette -------------------------------------------- +def test_deploy_voice_pushes_and_records(tmp_path): s = _store(tmp_path) - adb = _AdminAdb([_manifest("anon", "pending")]) - man = admin_ingest.list_manifests(adb, "D1")[0] - r = vo.ingest_voice(adb, s, _BridgeASR(), "D1", man, delete_source=False, now=NOW) - assert r["deployed"] is False and r["reason"] == "pending" - assert adb.pushed == [] # rien déployé - assert s.voice_record("D1", "anon")["archived_at"] == NOW # mais WAV archivé - assert s.voice_record("D1", "anon")["scope"] == "pending" + s.archive_voice_wav("paul", b"WAV", origin_serial="D1", source_wav_path=None, now=NOW) + s.store_voice_ovsp("paul", b"OVSP", now=NOW) + s.set_voice_manifest("paul", scope="global", owner_name=None, consent_text=None, now=NOW) + adb = _Adb() + r = vo.deploy_voice(adb, s, _Bridge(), "D1", "paul", now=NOW) + assert r["deployed"] and adb.pushed[0][1] == _os.path.join(_OVDIR, "paul.ovsp") + assert "D1" in s.deployments_of("paul") -def test_ingest_global_deploys(tmp_path): +def test_deploy_voice_enrolls_if_no_ovsp(tmp_path): s = _store(tmp_path) - adb = _AdminAdb([_manifest("paul", "global")]) - man = admin_ingest.list_manifests(adb, "D1")[0] - r = vo.ingest_voice(adb, s, _BridgeASR(), "D1", man, delete_source=False, now=NOW) - assert r["deployed"] is True and r["scope"] == "global" - assert adb.pushed and adb.pushed[0][1] == _os.path.join(_OVDIR, "paul.ovsp") - assert s.voice_record("D1", "paul")["locked_profile_id"] is None + s.archive_voice_wav("paul", b"WAV", origin_serial="D1", source_wav_path=None, now=NOW) + s.set_voice_manifest("paul", scope="global", owner_name=None, consent_text=None, now=NOW) + adb = _Adb() + r = vo.deploy_voice(adb, s, _Bridge(), "D1", "paul", now=NOW) # pas d'ovsp → enrôle + assert r["deployed"] and s.voice_ovsp_bytes("paul") == b"OVSP-bytes" -def test_ingest_exclusive_locks_and_deploys(tmp_path): +def test_deploy_voice_refuses_exclusive_absent_owner(tmp_path): s = _store(tmp_path) - adb = _AdminAdb([_manifest("marie", "exclusive", owner="p1")], profiles=["p1", "p2"]) - man = admin_ingest.list_manifests(adb, "D1")[0] - r = vo.ingest_voice(adb, s, _BridgeASR(), "D1", man, delete_source=False, now=NOW) - assert r["deployed"] is True - assert s.voice_record("D1", "marie")["locked_profile_id"] == "p1" - - -def test_ingest_exclusive_owner_absent_skips_deploy(tmp_path): - s = _store(tmp_path) - adb = _AdminAdb([_manifest("marie", "exclusive", owner="p_absent")], profiles=["p1"]) - man = admin_ingest.list_manifests(adb, "D1")[0] - r = vo.ingest_voice(adb, s, _BridgeASR(), "D1", man, delete_source=False, now=NOW) + s.archive_voice_wav("marie", b"WAV", origin_serial="D1", source_wav_path=None, now=NOW) + s.store_voice_ovsp("marie", b"OVSP", now=NOW) + s.lock_voice("marie", "p_absent", now=NOW) + adb = _Adb(profiles=["p1"]) + r = vo.deploy_voice(adb, s, _Bridge(), "D1", "marie", now=NOW) assert r["deployed"] is False and r["reason"] == "verrou_profil_absent" assert adb.pushed == [] - # archivée + verrouillée quand même (juste pas déployée ici) - assert s.voice_record("D1", "marie")["locked_profile_id"] == "p_absent" -def test_sync_admin_delete_source_cleans_admin(tmp_path): +def test_undeploy_voice(tmp_path): s = _store(tmp_path) - adb = _AdminAdb([_manifest("paul", "global")]) - rep = vo.sync_admin(adb, s, _BridgeASR(), "D1", delete_source=True, now=NOW) + s.archive_voice_wav("paul", b"x", origin_serial="D1", source_wav_path=None, now=NOW) + s.add_deployment("paul", "D1", now=NOW) + adb = _Adb() + vo.undeploy_voice(adb, s, "D1", "paul", now=NOW) + assert s.deployments_of("paul") == [] + assert any("paul.ovsp" in r for r in adb.removed) + + +def test_list_for_tablet_split(tmp_path): + s = _store(tmp_path) + # paul déployé sur D1 ; marie exclusive p2 (absente) ; léa globale non déployée + for vid, scope in (("paul", "global"), ("lea", "global")): + s.archive_voice_wav(vid, b"x", origin_serial="D1", source_wav_path=None, now=NOW) + s.set_voice_manifest(vid, scope=scope, owner_name=None, consent_text=None, now=NOW) + s.archive_voice_wav("marie", b"x", origin_serial="D1", source_wav_path=None, now=NOW) + s.lock_voice("marie", "p2", now=NOW) + adb = _Adb(profiles=["p1"], deployed=["paul"]) + view = vo.list_for_tablet(adb, s, "D1") + dep = {v["voice_id"] for v in view["deployed"]} + able = {v["voice_id"]: v for v in view["deployable"]} + assert "paul" in dep + assert able["lea"]["deployable"] is True + assert able["marie"]["deployable"] is False and able["marie"]["reason"] == "verrou_profil_absent" + + +# ---- ingestion admin → catalogue global ---------------------------------- +def test_ingest_global_archives_and_deploys(tmp_path): + s = _store(tmp_path) + adb = _Adb([_manifest("paul", "global")]) + man = admin_ingest.list_manifests(adb, "D1")[0] + r = vo.ingest_voice(adb, s, _Bridge(), "D1", man, delete_source=False, now=NOW) + assert r["deployed"] and r["scope"] == "global" + rec = s.voice_record("paul") + assert rec["origin_serial"] == "D1" and "D1" in rec["deployed_serials"] + + +def test_ingest_pending_no_deploy(tmp_path): + s = _store(tmp_path) + adb = _Adb([_manifest("anon", "pending")]) + man = admin_ingest.list_manifests(adb, "D1")[0] + r = vo.ingest_voice(adb, s, _Bridge(), "D1", man, delete_source=False, now=NOW) + assert r["deployed"] is False and r["reason"] == "pending" + assert adb.pushed == [] and s.voice_record("anon")["archived_at"] == NOW + + +def test_ingest_exclusive_locks(tmp_path): + s = _store(tmp_path) + adb = _Adb([_manifest("marie", "exclusive", owner="p1")], profiles=["p1"]) + man = admin_ingest.list_manifests(adb, "D1")[0] + r = vo.ingest_voice(adb, s, _Bridge(), "D1", man, delete_source=False, now=NOW) + assert r["deployed"] and s.voice_record("marie")["locked_profile_id"] == "p1" + + +def test_sync_admin_delete_cleans(tmp_path): + s = _store(tmp_path) + adb = _Adb([_manifest("paul", "global")]) + rep = vo.sync_admin(adb, s, _Bridge(), "D1", delete_source=True, now=NOW) assert rep[0]["ok"] and rep[0]["deployed"] and rep[0]["wav_deleted"] - # WAV + manifeste supprimés du stockage admin assert any(p.endswith("paul.wav") for p in adb.removed) - assert any(p.endswith("paul.json") for p in adb.removed) + + +# ---- déclencheur auto ----------------------------------------------------- +def test_autosync_blocked_when_locked(tmp_path): + from kazeia_central.voice.autosync import VoiceAutoSync + locked = Store(tmp_path / "l.db", kdf_ops=_OPS, kdf_mem=_MEM) # pas unlock + a = VoiceAutoSync(_Adb([_manifest("paul", "global")]), locked, _Bridge()) + assert a.sync_now("D1")["state"] == "store_verrouille" + + +def test_autosync_enabled_ingests(tmp_path): + from kazeia_central.voice.autosync import VoiceAutoSync + s = _store(tmp_path) + a = VoiceAutoSync(_Adb([_manifest("paul", "global")]), s, _Bridge()) + a.set_enabled(True, delete_source=False) + st = a.sync_now("D1") + assert st["state"] == "termine" and st["enrolled"] == 1