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:
parent
cdee5151f4
commit
6640cfd79e
|
|
@ -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 "/".
|
||||||
|
|
|
||||||
|
|
@ -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 ré-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 ré-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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 ré-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 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).
|
# 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, *,
|
||||||
|
|
|
||||||
|
|
@ -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,""")}">${(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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue