feat(voice): catalogue GLOBAL (flotte) + déploiement par tablette

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) <noreply@anthropic.com>
This commit is contained in:
alf 2026-06-23 19:39:00 +02:00
parent cdee5151f4
commit 6640cfd79e
6 changed files with 540 additions and 657 deletions

View File

@ -66,6 +66,10 @@ class _Lock(BaseModel):
profile_id: str profile_id: str
class _Deploy(BaseModel):
voice_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
@ -231,8 +235,8 @@ def create_app(adb: Adb | None = None, store: Store | None = None,
def voices_health(): def voices_health():
return {"encoder_available": bridge.available(), "ov_python": bridge.ov_python} return {"encoder_available": bridge.available(), "ov_python": bridge.ov_python}
# ⚠️ Routes littérales sous /api/voices/ AVANT /{serial} sinon "autosync" est # ⚠️ Routes littérales sous /api/voices/ AVANT /{serial} (sinon "autosync"/"catalog"
# capté comme un serial (FastAPI matche dans l'ordre de déclaration). # captés comme un serial — FastAPI matche dans l'ordre de déclaration).
@app.get("/api/voices/autosync") @app.get("/api/voices/autosync")
def autosync_status(): def autosync_status():
return autosync.status() return autosync.status()
@ -245,12 +249,29 @@ def create_app(adb: Adb | None = None, store: Store | None = None,
now=_now_ms()) now=_now_ms())
return autosync.status() return autosync.status()
@app.get("/api/voices/{serial}") # ---- catalogue voix GLOBAL (vue sans tablette) ------------------------
def voices_list(serial: str): @app.get("/api/voices/catalog")
return _guard(lambda: vo.list_voices(adb, store, serial)) 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") @app.get("/api/voices/{serial}/admin")
def voices_admin_list(serial: str): def voices_admin_list(serial: str):
return _guard(lambda: vo.list_admin_voices(adb, store, serial)) 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, return _guard(lambda: vo.sync_admin(adb, store, bridge, serial,
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}/prepare") @app.post("/api/voices/{serial}/deploy")
def voices_prepare(serial: str, voice_id: str): def voices_deploy(serial: str, body: _Deploy):
# pull + archive chiffré + transcription (ASR OmniVoice, à relire avant enrôlement) return _guard(lambda: vo.deploy_voice(adb, store, bridge, serial, body.voice_id,
return _guard(lambda: vo.prepare(adb, store, bridge, serial, voice_id, now=_now_ms(), actor=_OPERATOR))
now=_now_ms(), actor=_OPERATOR))
@app.post("/api/voices/{serial}/{voice_id}/enroll") @app.post("/api/voices/{serial}/undeploy")
def voices_enroll(serial: str, voice_id: str, body: _Enroll): def voices_undeploy(serial: str, body: _Deploy):
return _guard(lambda: vo.enroll_deploy( return _guard(lambda: vo.undeploy_voice(adb, store, serial, body.voice_id,
adb, store, bridge, serial, voice_id, body.transcription, 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 "/".

View File

@ -65,24 +65,33 @@ CREATE TABLE IF NOT EXISTS audit (
target TEXT, target TEXT,
detail TEXT -- métadonnée d'audit (pas de PII) → clair 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 ( CREATE TABLE IF NOT EXISTS voices (
serial TEXT NOT NULL, voice_id TEXT PRIMARY KEY,
voice_id TEXT NOT NULL, origin_serial TEXT, -- tablette d'enregistrement (métadonnée)
transcription_enc BLOB, -- texte parlé (PII possible) chiffré name TEXT,
language TEXT, transcription_enc BLOB, -- texte du .ovsp (ASR) chiffré
source_wav_path TEXT, -- chemin device d'origine source_wav_path TEXT, -- chemin device d'origine du WAV
wav_file TEXT, -- WAV chiffré archivé (relatif au dossier voices/) wav_file TEXT, -- WAV chiffré archivé (relatif au dossier voices/)
wav_sha256 TEXT, -- intégrité du WAV d'origine wav_sha256 TEXT,
ovsp_enc BLOB, -- artefact .ovsp chiffré déployable sans -enrôler
ovsp_bytes INTEGER, ovsp_bytes INTEGER,
archived_at INTEGER, -- WAV récupéré + archivé sur le PC archived_at INTEGER, -- WAV récupéré + archivé sur le PC
enrolled_at INTEGER, -- .ovsp produit enrolled_at INTEGER, -- .ovsp produit
deployed_at INTEGER, -- .ovsp poussé sur la tablette wav_deleted_at INTEGER, -- WAV supprimé de la tablette d'origine
wav_deleted_at INTEGER, -- WAV supprimé de la tablette locked_profile_id TEXT, -- exclusivité (= owner_profile_id ; NULL = généraliste)
locked_profile_id TEXT, -- assignation exclusive à un profil patient (NULL = généraliste) scope TEXT, -- exclusive | global | pending
scope TEXT, -- exclusive | global | pending (du manifeste admin §4.2) owner_name TEXT,
owner_name TEXT, -- libellé propriétaire (manifeste) consent_text_enc BLOB -- phrase de consentement (PII) chiffré
consent_text_enc BLOB, -- reference_text = phrase de consentement (PII) chiffré );
PRIMARY KEY (serial, voice_id) -- 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 """Migrations légères idempotentes (CREATE TABLE IF NOT EXISTS ne fait pas
évoluer une table déjà créée).""" évoluer une table déjà créée)."""
cols = {r["name"] for r in self._db.execute("PRAGMA table_info(voices)")} cols = {r["name"] for r in self._db.execute("PRAGMA table_info(voices)")}
if "locked_profile_id" not in cols: if "serial" in cols: # ANCIEN schéma per-tablette → mises à niveau puis refonte
self._db.execute("ALTER TABLE voices ADD COLUMN locked_profile_id TEXT") if "locked_profile_id" not in cols:
# Migration OmniVoice (23/06/2026) : cvps_bytes → ovsp_bytes. self._db.execute("ALTER TABLE voices ADD COLUMN locked_profile_id TEXT")
if "cvps_bytes" in cols and "ovsp_bytes" not in cols: if "cvps_bytes" in cols and "ovsp_bytes" not in cols:
self._db.execute("ALTER TABLE voices RENAME COLUMN cvps_bytes TO ovsp_bytes") 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"),
for col, decl in (("scope", "TEXT"), ("owner_name", "TEXT"), ("consent_text_enc", "BLOB")):
("consent_text_enc", "BLOB")): if col not in cols:
if col not in cols: self._db.execute(f"ALTER TABLE voices ADD COLUMN {col} {decl}")
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
(`<serial>/<id>.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: def close(self) -> None:
with self._lock: with self._lock:
@ -293,120 +321,142 @@ class Store:
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
return d return d
def _voice_upsert(self, serial: str, voice_id: str, **cols: Any) -> None: def _voice_upsert(self, voice_id: str, **cols: Any) -> None:
"""Upsert partiel d'une ligne voix (colonnes données seulement).""" """Upsert partiel d'une voix du catalogue global (colonnes données seulement)."""
keys = list(cols) keys = list(cols)
sets = ", ".join(f"{k}=excluded.{k}" for k in keys) sets = ", ".join(f"{k}=excluded.{k}" for k in keys)
self._db.execute( self._db.execute(
f"INSERT INTO voices(serial, voice_id, {', '.join(keys)}) " f"INSERT INTO voices(voice_id, {', '.join(keys)}) "
f"VALUES(?, ?, {', '.join('?' for _ in keys)}) " f"VALUES(?, {', '.join('?' for _ in keys)}) "
f"ON CONFLICT(serial, voice_id) DO UPDATE SET {sets}", f"ON CONFLICT(voice_id) DO UPDATE SET {sets}",
(serial, voice_id, *[cols[k] for k in keys]), (voice_id, *[cols[k] for k in keys]),
) )
self._db.commit() self._db.commit()
def archive_voice_wav(self, serial: str, voice_id: str, wav_bytes: bytes, *, def archive_voice_wav(self, voice_id: str, wav_bytes: bytes, *, origin_serial: str | None,
source_wav_path: str | None, now: int) -> str: source_wav_path: str | None, name: str | None = None, now: int) -> str:
"""Chiffre + archive le WAV d'origine sur le PC (le « garder sur Kazeia-central » """Chiffre + archive le WAV d'origine sur le PC (asset global, pas lié à une
une fois supprimé de la tablette). Renvoie le chemin du fichier chiffré.""" tablette). Renvoie le chemin du fichier chiffré."""
v = self._require_vault() v = self._require_vault()
sha = hashlib.sha256(wav_bytes).hexdigest() sha = hashlib.sha256(wav_bytes).hexdigest()
dest = self._voices_dir() / serial / f"{voice_id}.wav.enc" dest = self._voices_dir() / f"{voice_id}.wav.enc"
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(v.seal_bytes(wav_bytes)) dest.write_bytes(v.seal_bytes(wav_bytes))
with self._lock: 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_file=str(dest.relative_to(self._voices_dir())),
wav_sha256=sha, archived_at=now) wav_sha256=sha, archived_at=now)
return str(dest) 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).""" """Relit le WAV archivé déchiffré (pour ré-enrôlement)."""
v = self._require_vault() v = self._require_vault()
with self._lock: with self._lock:
row = self._db.execute( row = self._db.execute(
"SELECT wav_file FROM voices WHERE serial=? AND voice_id=?", "SELECT wav_file FROM voices WHERE voice_id=?", (voice_id,)).fetchone()
(serial, voice_id)).fetchone()
if not row or not row["wav_file"]: if not row or not row["wav_file"]:
return None return None
return v.open_bytes((self._voices_dir() / row["wav_file"]).read_bytes()) return v.open_bytes((self._voices_dir() / row["wav_file"]).read_bytes())
def set_voice_transcription(self, serial: str, voice_id: str, text: str, def set_voice_transcription(self, voice_id: str, text: str, *, now: int) -> None:
language: str | None, *, now: int) -> None:
v = self._require_vault() v = self._require_vault()
with self._lock: with self._lock:
self._voice_upsert(serial, voice_id, transcription_enc=v.seal(text), self._voice_upsert(voice_id, transcription_enc=v.seal(text))
language=language)
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 -enrôler. Marque la voix enrôlée."""
v = self._require_vault()
with self._lock: 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: 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: 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).""" """Assigne une voix exclusivement à un profil patient (verrou)."""
with self._lock: 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.""" """Lève le verrou → voix redevient généraliste."""
with self._lock: 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: owner_name: str | None, consent_text: str | None, now: int) -> None:
"""Métadonnées du manifeste admin (§4.2) : portée + propriétaire + consentement """Métadonnées du manifeste admin (§4.2) : portée + propriétaire + consentement
(chiffré). Le consentement = phrase nominative PII.""" (chiffré). Le consentement = phrase nominative PII."""
v = self._require_vault() v = self._require_vault()
with self._lock: 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)) 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: with self._lock:
row = self._db.execute( row = self._db.execute(
"SELECT locked_profile_id FROM voices WHERE serial=? AND voice_id=?", "SELECT locked_profile_id FROM voices WHERE voice_id=?", (voice_id,)).fetchone()
(serial, voice_id)).fetchone()
return row["locked_profile_id"] if row else None 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() v = self._require_vault()
with self._lock: with self._lock:
row = self._db.execute( row = self._db.execute(
"SELECT * FROM voices WHERE serial=? AND voice_id=?", "SELECT * FROM voices WHERE voice_id=?", (voice_id,)).fetchone()
(serial, voice_id)).fetchone() return self._voice_dict(row, v) if row else None
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
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() v = self._require_vault()
with self._lock: with self._lock:
q = "SELECT * FROM voices" rows = self._db.execute("SELECT * FROM voices ORDER BY voice_id").fetchall()
args: tuple = () return [self._voice_dict(r, v) for r in rows]
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
# ---- audit (§8) ------------------------------------------------------- # ---- audit (§8) -------------------------------------------------------
def audit(self, action: str, *, actor: str | None = None, target: str | None = None, def audit(self, action: str, *, actor: str | None = None, target: str | None = None,

View File

@ -1,7 +1,7 @@
"""Déclencheur automatique d'enrôlement à la connexion d'une tablette. """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 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. 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 Sécurité : ne fait rien si le store est verrouillé (les PII restent protégées) ; la

View File

@ -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), Modèle (refonte 23/06/2026) : une voix est un **asset de flotte** (catalogue central,
store chiffré. Tourne sous l'API py3.14 ; le calcul lourd est délégué au worker. 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 -enrôler.
Workflow (VOICE_ENROLLMENT_SPEC §1, décisions 2026-06-19) : Source des voix fraîches : stockage ADMIN (`admin_ingest`, manifestes scope/owner/
connexion lister voix sans .ovsp pull WAV ARCHIVER (store chiffré) reference_text). Politique : `global` déployable partout ; `exclusive` seulement
transcrire (ASR OmniVoice, relecture opérateur) enrôler (.ovsp) pousser sur la sur une tablette portant le profil propriétaire ; `pending` archivée, pas déployable.
tablette SUPPRIMER le WAV de la tablette (gardé sur Kazeia-central). Le `.ovsp` porte l'ASR du segment 16 s (§6) ; le consentement reste en métadonnée.
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.
""" """
from __future__ import annotations from __future__ import annotations
@ -26,169 +24,6 @@ from ..store import Store
from . import admin_ingest, transfer from . import admin_ingest, transfer
from .bridge import VoiceBridge 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 -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). # MODELS_DIR candidats côté patient (dev legacy / prod scoped-storage).
_CANDIDATE_MODELS_DIRS = ( _CANDIDATE_MODELS_DIRS = (
"/data/local/tmp/kazeia/models", "/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: def _patient_omnivoice_dir(adb: Adb, serial: str) -> str:
"""Dossier `.ovsp` cible côté patient (`<MODELS_DIR>/omnivoice/voices`). Les """Dossier `.ovsp` cible (`<MODELS_DIR>/omnivoice/voices`). Post-migration
manifestes admin ne le donnent pas, et post-migration `/voices.wav_path` est VIDE `/voices.wav_path` est VIDE cascade : env wav_path legacy sonde device."""
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."""
env = os.environ.get("KAZEIA_OMNIVOICE_DIR") env = os.environ.get("KAZEIA_OMNIVOICE_DIR")
if env: if env:
return env return env
@ -210,14 +54,109 @@ def _patient_omnivoice_dir(adb: Adb, serial: str) -> str:
for base in _CANDIDATE_MODELS_DIRS: for base in _CANDIDATE_MODELS_DIRS:
if base in adb.shell(f"ls -d {base} 2>/dev/null", serial=serial): if base in adb.shell(f"ls -d {base} 2>/dev/null", serial=serial):
return posixpath.join(base, "omnivoice", "voices") return posixpath.join(base, "omnivoice", "voices")
raise ValueError( raise ValueError("dossier omnivoice indéterminable (définir KAZEIA_OMNIVOICE_DIR)")
"dossier omnivoice indéterminable (ni /voices wav_path, ni dossier modèles connu ; "
"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: def _admin_status(man: dict, rec: dict, deployed: bool) -> str:
if man["scope"] == "pending": if man["scope"] == "pending":
return "pending" # ni enrôlée ni déployable tant que non résolue return "pending"
if deployed: if deployed:
return "deployed" return "deployed"
if rec.get("enrolled_at"): 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]]: 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 """Voix enregistrées via l'admin (manifestes) croisées avec le catalogue global."""
l'état de déploiement. C'est la liste à piloter depuis l'UI."""
manifests = admin_ingest.list_manifests(adb, serial) 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: try:
deployed = transfer.deployed_ovsp(adb, serial, _patient_omnivoice_dir(adb, serial)) deployed = transfer.deployed_ovsp(adb, serial, _patient_omnivoice_dir(adb, serial))
except Exception: 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, *, 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]: 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/ """Ingère UNE voix admin au **catalogue global** : pull WAV → archive chiffrée
consentement) verrou si exclusive enrôle (ASR du 16 s, §6) déploie selon scope. (origin=cette tablette) métadonnées (scope/owner/consentement) verrou si
`pending` = archivée mais NI enrôlée NI déployée (cadre à fixer à l'attribution).""" exclusive enrôle + déploie sur la tablette d'origine (selon scope)."""
vid = manifest["id"] vid = manifest["id"]
scope = manifest["scope"] scope = manifest["scope"]
owner = manifest.get("owner_profile_id") owner = manifest.get("owner_profile_id")
# 1) Archiver le WAV (depuis le stockage admin) s'il ne l'est pas déjà. if store.voice_wav_bytes(vid) is None:
data = store.voice_wav_bytes(serial, vid)
if data is None:
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
local = os.path.join(td, f"{vid}.wav") local = os.path.join(td, f"{vid}.wav")
transfer.pull_wav(adb, serial, manifest["wav_device_path"], local) transfer.pull_wav(adb, serial, manifest["wav_device_path"], local)
data = open(local, "rb").read() 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) source_wav_path=manifest["wav_device_path"], now=now)
store.set_voice_manifest(vid, scope=scope, owner_name=manifest.get("owner_name"),
# 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"),
consent_text=manifest.get("reference_text"), now=now) consent_text=manifest.get("reference_text"), now=now)
if scope == "exclusive" and owner: 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}", store.audit("voice_admin_ingest", actor=actor, target=f"{serial}/{vid}",
detail={"scope": scope, "owner": owner}, now=now) detail={"scope": scope, "owner": owner}, now=now)
# 3) pending → on s'arrête (archivée, à résoudre plus tard).
if scope == "pending": if scope == "pending":
return {"voice_id": vid, "scope": scope, "deployed": False, "reason": "pending"} return {"voice_id": vid, "scope": scope, "deployed": False, "reason": "pending"}
# 4) exclusive → le profil propriétaire doit être présent sur la tablette. res = deploy_voice(adb, store, bridge, serial, vid, now=now, actor=actor)
if scope == "exclusive" and owner: out = {"voice_id": vid, "scope": scope, "deployed": res.get("deployed", False),
_, present = _profiles_using(adb, serial) "reason": res.get("reason"), "deployed_to": res.get("deployed_to"), "wav_deleted": False}
if owner not in present: if delete_source and res.get("deployed"):
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:
admin_ingest.delete_admin_voice(adb, serial, manifest) 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) store.audit("voice_wav_deleted", actor=actor, target=f"{serial}/{vid}", now=now)
result["wav_deleted"] = True out["wav_deleted"] = True
return result return out
def sync_admin(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, *, def sync_admin(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, *,

View File

@ -260,25 +260,41 @@ async function selectDevice(serial) {
d.innerHTML = `<div class="grid">${state}${updates}${rag}${profiles}${convs}${crashes}</div>`; d.innerHTML = `<div class="grid">${state}${updates}${rag}${profiles}${convs}${crashes}</div>`;
} }
// ---- 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)" }; const SCOPE_COLOR = { exclusive:"var(--warn)", global:"var(--ok)", pending:"var(--muted)" };
function scopeBadge(s) { function scopeBadge(s) {
return `<span class="badge" style="color:${SCOPE_COLOR[s]||"var(--muted)"};border-color:${SCOPE_COLOR[s]||"var(--line)"}">${s||"?"}</span>`; return `<span class="badge" style="color:${SCOPE_COLOR[s]||"var(--muted)"};border-color:${SCOPE_COLOR[s]||"var(--line)"}">${s||"?"}</span>`;
} }
async function voiceView() { async function voiceView() {
if (!selected) { $("detail").innerHTML = `<div id="placeholder">← sélectionne d'abord une tablette</div>`; return; }
if (!unlocked) { $("detail").innerHTML = `<div class="card"><h3>Voix</h3><div class="err">Déverrouille le store (colonne de gauche) pour gérer les voix.</div></div>`; return; } if (!unlocked) { $("detail").innerHTML = `<div class="card"><h3>Voix</h3><div class="err">Déverrouille le store (colonne de gauche) pour gérer les voix.</div></div>`; return; }
const serial = selected; const serial = selected; // peut être null → vue catalogue seule
const d = $("detail"); const d = $("detail");
d.innerHTML = `<div class="muted">chargement des voix de <b>${serial}</b></div>`; d.innerHTML = `<div class="muted">chargement du catalogue voix…</div>`;
const [auto, admin, deployed] = await Promise.all([ const reqs = [api("/voices/catalog").catch(e=>({error:e.message})),
api("/voices/autosync").catch(e=>({error:e.message})), api("/voices/autosync").catch(e=>({error:e.message}))];
api(`/voices/${serial}/admin`).catch(e=>({error:e.message})), if (serial) reqs.push(api(`/voices/${serial}`).catch(e=>({error:e.message})),
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 ? `<div class="err">${cat.error}</div>` :
(cv.length ? `<table>
<tr><th>voix</th><th>portée</th><th>verrou</th><th>déployée sur</th><th></th></tr>
${cv.map(v=>`<tr>
<td><b>${v.name||v.voice_id}</b><div class="serial">${v.voice_id}</div></td>
<td>${scopeBadge(v.scope)}</td>
<td>${v.locked_profile_id?`🔒 ${v.owner_name||v.locked_profile_id}`:"<span class='muted'>généraliste</span>"}</td>
<td class="muted">${(v.deployed_serials||[]).length?(v.deployed_serials).join(", "):"—"}</td>
<td>${v.locked_profile_id
? `<button onclick="unlockVoice('${v.voice_id}')">déverrouiller</button>`
: `<button onclick="lockVoice('${v.voice_id}')">verrouiller…</button>`}</td>
</tr>`).join("")}
</table>` : `<span class="muted">catalogue vide — enregistre des voix via l'app admin puis synchronise</span>`), "full");
// 2) déclencheur auto
const aStat = auto?.devices?.[serial]; const aStat = auto?.devices?.[serial];
const autoCard = card("Déclencheur automatique", const autoCard = card("Déclencheur automatique",
auto.error ? `<div class="err">${auto.error}</div>` : auto.error ? `<div class="err">${auto.error}</div>` :
@ -286,48 +302,41 @@ async function voiceView() {
<div class="row" style="display:flex;gap:8px;align-items:center;margin-top:8px"> <div class="row" style="display:flex;gap:8px;align-items:center;margin-top:8px">
<button onclick="toggleAutosync(${!auto.enabled})">${auto.enabled?"Désarmer":"Armer"}</button> <button onclick="toggleAutosync(${!auto.enabled})">${auto.enabled?"Désarmer":"Armer"}</button>
<label class="muted" style="font-size:12px"><input type="checkbox" id="auto_del" ${auto.delete_source?"checked":""}> supprimer le WAV device après</label> <label class="muted" style="font-size:12px"><input type="checkbox" id="auto_del" ${auto.delete_source?"checked":""}> supprimer le WAV device après</label>
</div> </div>${aStat?`<div class="muted" style="font-size:12px;margin-top:6px">Dernière sync ${serial} : <b>${aStat.state}</b>${aStat.enrolled!=null?` (${aStat.enrolled}/${aStat.total})`:""}</div>`:""}`);
<div class="muted" style="font-size:12px;margin-top:6px">À l'armement, toute tablette qui se (re)connecte est enrôlée automatiquement.
${aStat?`<br>Dernière sync ${serial} : <b>${aStat.state}</b>${aStat.enrolled!=null?` (${aStat.enrolled}/${aStat.total})`:""}`:""}</div>`);
// 2) voix admin à enrôler let cards = catCard + autoCard;
const av = Array.isArray(admin) ? admin : [];
const adminCard = card(`Voix enregistrées — admin (${av.length})`,
admin.error ? `<div class="err">${admin.error}</div>` :
(av.length ? `<table>
<tr><th>nom</th><th>portée</th><th>propriétaire</th><th>statut</th><th>consentement</th></tr>
${av.map(v=>`<tr>
<td><b>${v.name||v.voice_id}</b><div class="serial">${v.voice_id}</div></td>
<td>${scopeBadge(v.scope)}</td>
<td>${v.owner_name||v.owner_profile_id||"—"}</td>
<td>${v.status}${v.deployed_on_device?" ✓":""}</td>
<td class="muted" title="${(v.reference_text||"").replace(/"/g,"&quot;")}">${(v.reference_text||"—").slice(0,40)}${(v.reference_text||"").length>40?"…":""}</td>
</tr>`).join("")}
</table>` : `<span class="muted">aucune voix enregistrée côté admin</span>`) +
`<div class="row" style="display:flex;gap:8px;align-items:center;margin-top:10px">
<button id="syncbtn" onclick="syncAdmin('${serial}')">Synchroniser (enrôler + déployer)</button>
<label class="muted" style="font-size:12px"><input type="checkbox" id="sync_del"> supprimer le WAV device après</label>
<span id="syncmsg" class="muted"></span>
</div>`, "full");
// 3) voix déployées (verrou) if (!serial) {
const dv = Array.isArray(deployed) ? deployed : []; cards += card("Par tablette", `<span class="muted">Sélectionne une tablette (colonne de gauche), puis reclique 🎙 Voix : tu verras ce qui y est chargé et les voix chargeables dessus.</span>`, "full");
const depCard = card(`Voix déployées sur la tablette (${dv.filter(v=>v.deployed_on_device).length})`, } else {
deployed.error ? `<div class="err">${deployed.error}</div>` : // 3) voix admin à enrôler sur cette tablette
(dv.length ? `<table> const av = Array.isArray(admin) ? admin : [];
<tr><th>voix</th><th>statut</th><th>verrou</th><th>profils</th><th></th></tr> const adminCard = card(`Voix admin à enrôler — ${serial} (${av.length})`,
${dv.map(v=>`<tr> admin.error ? `<div class="err">${admin.error}</div>` :
<td>${v.voice_id}</td> (av.length ? `<table><tr><th>nom</th><th>portée</th><th>propriétaire</th><th>statut</th></tr>
<td>${v.deployed_on_device?dot(true)+"déployée":v.status}</td> ${av.map(v=>`<tr><td><b>${v.name||v.voice_id}</b></td><td>${scopeBadge(v.scope)}</td><td>${v.owner_name||v.owner_profile_id||"—"}</td><td>${v.status}</td></tr>`).join("")}
<td>${v.locked_profile_id?`🔒 ${v.locked_profile_id}`:"<span class='muted'>généraliste</span>"}</td> </table>` : `<span class="muted">aucune voix admin en attente</span>`) +
<td class="muted">${(v.used_by_profiles||[]).join(", ")||"—"}</td> `<div class="row" style="display:flex;gap:8px;align-items:center;margin-top:10px">
<td>${v.locked_profile_id <button id="syncbtn" onclick="syncAdmin('${serial}')">Synchroniser (enrôler + déployer)</button>
? `<button onclick="unlockVoice('${serial}','${v.voice_id}')">déverrouiller</button>` <label class="muted" style="font-size:12px"><input type="checkbox" id="sync_del"> supprimer le WAV device après</label>
: `<button onclick="lockVoice('${serial}','${v.voice_id}')">verrouiller…</button>`}</td> <span id="syncmsg" class="muted"></span></div>`, "full");
</tr>`).join("")}
</table>` : `<span class="muted">aucune voix</span>`), "full");
d.innerHTML = `<div class="grid">${autoCard}${adminCard}${depCard}</div>`; // 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 ? `<div class="err">${tablet.error}</div>` :
`<table><tr><th>voix</th><th>portée</th><th>état</th><th></th></tr>
${loaded.map(v=>`<tr><td>${dot(true)}${v.name||v.voice_id}${v.in_catalog===false?" <span class='muted'>(hors catalogue)</span>":""}</td>
<td>${v.scope?scopeBadge(v.scope):"—"}</td><td>chargée</td>
<td>${v.in_catalog!==false?`<button onclick="undeployVoice('${serial}','${v.voice_id}')">retirer</button>`:""}</td></tr>`).join("")}
${loadable.map(v=>`<tr><td class="muted">${v.name||v.voice_id}</td><td>${scopeBadge(v.scope)}</td>
<td class="muted">${v.deployable?"chargeable":(v.reason||"—")}</td>
<td>${v.deployable?`<button onclick="deployVoice('${serial}','${v.voice_id}')">charger</button>`:""}</td></tr>`).join("")}
</table>`, "full");
cards += adminCard + tabCard;
}
d.innerHTML = `<div class="grid">${cards}</div>`;
} }
async function toggleAutosync(on) { async function toggleAutosync(on) {
@ -344,22 +353,39 @@ async function syncAdmin(serial) {
try { try {
const rep = await api(`/voices/${serial}/admin/sync`, {method:"POST", const rep = await api(`/voices/${serial}/admin/sync`, {method:"POST",
headers:{"content-type":"application/json"}, body: JSON.stringify({delete_source:del})}); headers:{"content-type":"application/json"}, body: JSON.stringify({delete_source:del})});
const ok = rep.filter(r=>r.ok && r.deployed).length; msg.textContent = `${rep.filter(r=>r.ok && r.deployed).length}/${rep.length} voix déployée(s).`;
msg.textContent = `${ok}/${rep.length} voix déployée(s).`;
await voiceView(); await voiceView();
} catch (e) { msg.textContent = "erreur : " + e.message; btn.disabled = false; } } 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)`); const pid = prompt(`Verrouiller « ${vid} » sur quel profil patient ? (profile_id)`);
if (!pid) return; 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})}); headers:{"content-type":"application/json"}, body: JSON.stringify({profile_id:pid})});
voiceView(); voiceView();
} }
async function unlockVoice(serial, vid) { async function unlockVoice(vid) {
await api(`/voices/${serial}/${vid}/unlock`, {method:"POST"}); await api(`/voices/catalog/${vid}/unlock`, {method:"POST"});
voiceView(); voiceView();
} }

View File

@ -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 Le moteur lourd (OmniVoice) n'est PAS testé ici (validé live) — faux bridge + faux adb
bridge et un faux adb pour prouver la COMPOSITION (workflow) sans le modèle. pour prouver la COMPOSITION (catalogue, déploiement scope-conscient, ingestion).
""" """
import json as _json
import os as _os
import tempfile
import nacl.pwhash import nacl.pwhash
from kazeia_central.adb import Adb from kazeia_central.adb import Adb
from kazeia_central.adb.client import AdbDevice from kazeia_central.adb.client import AdbDevice
from kazeia_central.store import Store 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 orchestrator as vo
from kazeia_central.voice import transfer from kazeia_central.voice import transfer
_OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN _OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN
_MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN _MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN
NOW = 1_700_000_000_000 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): def _store(tmp_path):
@ -23,208 +31,10 @@ def _store(tmp_path):
return s 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): def _manifest(vid, scope, owner=None):
m = {"id": vid, "name": vid.title(), "state": "recorded", "wav": f"{vid}.wav", m = {"id": vid, "name": vid.title(), "state": "recorded", "wav": f"{vid}.wav",
"duration_seconds": 16.0, "created_at": 1, "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} "source_device": "tablet-admin-recording", "scope": scope}
if owner: if owner:
m["owner_profile_id"] = owner m["owner_profile_id"] = owner
@ -232,8 +42,9 @@ def _manifest(vid, scope, owner=None):
return m return m
class _AdminAdb(Adb): class _Adb(Adb):
def __init__(self, manifests, profiles=(), deployed=()): """Faux adb : manifestes admin, profils, dossier omnivoice (déployés), push/rm."""
def __init__(self, manifests=(), profiles=(), deployed=()):
super().__init__() super().__init__()
self._manifests = {m["id"]: m for m in manifests} self._manifests = {m["id"]: m for m in manifests}
self._profiles = list(profiles) self._profiles = list(profiles)
@ -254,7 +65,7 @@ class _AdminAdb(Adb):
def shell(self, command, *, serial=None, timeout=None): def shell(self, command, *, serial=None, timeout=None):
if command.startswith(f"ls {_ADMIN}"): if command.startswith(f"ls {_ADMIN}"):
return "\n".join(f"{i}.json" for i in self._manifests) 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) return "\n".join(f"{d}.ovsp" for d in self._deployed)
if "rm -f" in command: if "rm -f" in command:
self.removed.append(command.split("rm -f ")[1].split(" ")[0]) 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)) self.pushed.append((open(local, "rb").read(), remote))
class _BridgeASR: class _Bridge:
def enroll(self, wav, text, out): def enroll(self, wav, text, out):
open(out, "wb").write(b"OVSP-asr") open(out, "wb").write(b"OVSP-bytes")
return {"bytes": 1234, "t_ref": 62, "ref_text": "transcription asr du segment"} return {"bytes": 9, "t_ref": 62, "ref_text": "asr du segment"}
def test_admin_list_manifests_parse(): # ---- chemins -------------------------------------------------------------
adb = _AdminAdb([_manifest("marie", "exclusive", owner="p1"), _manifest("paul", "global")]) def test_omnivoice_dir_marker():
out = {v["id"]: v for v in admin_ingest.list_manifests(adb, "D1")} assert transfer.omnivoice_dir_for("/data/local/tmp/kazeia/models/../voix/voix/a.wav") \
assert out["marie"]["scope"] == "exclusive" and out["marie"]["owner_profile_id"] == "p1" == "/data/local/tmp/kazeia/models/omnivoice/voices"
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
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 = _store(tmp_path)
s.set_voice_manifest("D1", "marie", scope="exclusive", owner_name="Patient p1", s.archive_voice_wav("marie_1", b"WAV", origin_serial="DA", source_wav_path="/x", name="Marie", now=NOW)
consent_text="Phrase SECRET-marie", now=NOW) s.store_voice_ovsp("marie_1", b"OVSP", now=NOW)
rec = s.voice_record("D1", "marie") s.lock_voice("marie_1", "p1", now=NOW)
assert rec["scope"] == "exclusive" and rec["consent_text"] == "Phrase SECRET-marie" 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() 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) s = _store(tmp_path)
adb = _AdminAdb([_manifest("anon", "pending")]) s.archive_voice_wav("paul", b"WAV", origin_serial="D1", source_wav_path=None, now=NOW)
man = admin_ingest.list_manifests(adb, "D1")[0] s.store_voice_ovsp("paul", b"OVSP", now=NOW)
r = vo.ingest_voice(adb, s, _BridgeASR(), "D1", man, delete_source=False, now=NOW) s.set_voice_manifest("paul", scope="global", owner_name=None, consent_text=None, now=NOW)
assert r["deployed"] is False and r["reason"] == "pending" adb = _Adb()
assert adb.pushed == [] # rien déployé r = vo.deploy_voice(adb, s, _Bridge(), "D1", "paul", now=NOW)
assert s.voice_record("D1", "anon")["archived_at"] == NOW # mais WAV archivé assert r["deployed"] and adb.pushed[0][1] == _os.path.join(_OVDIR, "paul.ovsp")
assert s.voice_record("D1", "anon")["scope"] == "pending" 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) s = _store(tmp_path)
adb = _AdminAdb([_manifest("paul", "global")]) s.archive_voice_wav("paul", b"WAV", origin_serial="D1", source_wav_path=None, now=NOW)
man = admin_ingest.list_manifests(adb, "D1")[0] s.set_voice_manifest("paul", scope="global", owner_name=None, consent_text=None, now=NOW)
r = vo.ingest_voice(adb, s, _BridgeASR(), "D1", man, delete_source=False, now=NOW) adb = _Adb()
assert r["deployed"] is True and r["scope"] == "global" r = vo.deploy_voice(adb, s, _Bridge(), "D1", "paul", now=NOW) # pas d'ovsp → enrôle
assert adb.pushed and adb.pushed[0][1] == _os.path.join(_OVDIR, "paul.ovsp") assert r["deployed"] and s.voice_ovsp_bytes("paul") == b"OVSP-bytes"
assert s.voice_record("D1", "paul")["locked_profile_id"] is None
def test_ingest_exclusive_locks_and_deploys(tmp_path): def test_deploy_voice_refuses_exclusive_absent_owner(tmp_path):
s = _store(tmp_path) s = _store(tmp_path)
adb = _AdminAdb([_manifest("marie", "exclusive", owner="p1")], profiles=["p1", "p2"]) s.archive_voice_wav("marie", b"WAV", origin_serial="D1", source_wav_path=None, now=NOW)
man = admin_ingest.list_manifests(adb, "D1")[0] s.store_voice_ovsp("marie", b"OVSP", now=NOW)
r = vo.ingest_voice(adb, s, _BridgeASR(), "D1", man, delete_source=False, now=NOW) s.lock_voice("marie", "p_absent", now=NOW)
assert r["deployed"] is True adb = _Adb(profiles=["p1"])
assert s.voice_record("D1", "marie")["locked_profile_id"] == "p1" r = vo.deploy_voice(adb, s, _Bridge(), "D1", "marie", now=NOW)
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)
assert r["deployed"] is False and r["reason"] == "verrou_profil_absent" assert r["deployed"] is False and r["reason"] == "verrou_profil_absent"
assert adb.pushed == [] 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) s = _store(tmp_path)
adb = _AdminAdb([_manifest("paul", "global")]) s.archive_voice_wav("paul", b"x", origin_serial="D1", source_wav_path=None, now=NOW)
rep = vo.sync_admin(adb, s, _BridgeASR(), "D1", delete_source=True, 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"] 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.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