diff --git a/kazeia_central/api/app.py b/kazeia_central/api/app.py index a673734..ac33b5d 100644 --- a/kazeia_central/api/app.py +++ b/kazeia_central/api/app.py @@ -267,6 +267,12 @@ def create_app(adb: Adb | None = None, store: Store | None = None, store.audit("voice_unlock", actor=_OPERATOR, target=voice_id, now=_now_ms()) return {"voice_id": voice_id, "locked_profile_id": None} + @app.post("/api/voices/catalog/{voice_id}/reenroll") + def voices_reenroll(voice_id: str): + # régénère le .ovsp (fix §7) depuis le WAV archivé + re-pousse sur ses tablettes + return _guard(lambda: vo.reenroll_voice(adb, store, bridge, voice_id, + now=_now_ms(), actor=_OPERATOR)) + # ---- vue par tablette : voix chargées + chargeables ------------------- @app.get("/api/voices/{serial}") def voices_for_tablet(serial: str): diff --git a/kazeia_central/voice/orchestrator.py b/kazeia_central/voice/orchestrator.py index e6720ef..e1356e4 100644 --- a/kazeia_central/voice/orchestrator.py +++ b/kazeia_central/voice/orchestrator.py @@ -126,6 +126,33 @@ def undeploy_voice(adb: Adb, store: Store, serial: str, voice_id: str, *, return {"voice_id": voice_id, "deployed": False} +def reenroll_voice(adb: Adb, store: Store, bridge: VoiceBridge, voice_id: str, *, + now: int, actor: str | None = None) -> dict[str, Any]: + """Régénère le `.ovsp` depuis le WAV archivé (ex. après le fix §7) et le re-pousse + sur toutes les tablettes où la voix est déployée. `deploy_voice` réutilise le `.ovsp` + stocké — donc sans ré-enrôlement, un artefact pollué reste pollué.""" + if store.voice_wav_bytes(voice_id) is None: + raise ValueError(f"WAV non archivé pour {voice_id} — ré-enrôlement impossible " + f"(ingérer d'abord depuis l'admin)") + info = _enroll_into_store(store, bridge, voice_id, now=now) # écrase ovsp + transcription + store.audit("voice_reenroll", actor=actor, target=voice_id, now=now) + ovsp_bytes = store.voice_ovsp_bytes(voice_id) + redeployed = [] + for serial in store.deployments_of(voice_id): + try: + 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) + transfer.push_ovsp(adb, serial, local, ov, voice_id) + store.add_deployment(voice_id, serial, now=now) + redeployed.append(serial) + except Exception as e: + redeployed.append(f"{serial}:erreur ({e})") + return {"voice_id": voice_id, "reenrolled": True, "ovsp_bytes": info["bytes"], + "ref_text": info.get("ref_text"), "redeployed": redeployed} + + 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é).""" diff --git a/kazeia_central/web/index.html b/kazeia_central/web/index.html index 8bd90e0..3a09feb 100644 --- a/kazeia_central/web/index.html +++ b/kazeia_central/web/index.html @@ -290,7 +290,8 @@ async function voiceView() { ${(v.deployed_serials||[]).length?(v.deployed_serials).join(", "):"—"} ${v.locked_profile_id ? `` - : ``} + : ``} + ${v.has_wav?``:""} `).join("")} ` : `catalogue vide — enregistre des voix via l'app admin puis synchronise`), "full"); @@ -389,6 +390,17 @@ async function unlockVoice(vid) { voiceView(); } +async function reenrollVoice(vid) { + if (!confirm(`Ré-enrôler « ${vid} » (fix §7) et re-pousser sur ses tablettes ? (~1 min)`)) return; + const m = document.createElement("div"); m.className = "muted"; + m.textContent = `ré-enrôlement de ${vid}… (~1 min, ne ferme pas)`; $("detail").prepend(m); + try { + const r = await api(`/voices/catalog/${vid}/reenroll`, {method:"POST"}); + alert(`ré-enrôlé.\nref_text : ${r.ref_text || "?"}\nre-déployé : ${(r.redeployed || []).join(", ") || "—"}`); + await voiceView(); + } catch (e) { alert("erreur : " + e.message); } +} + (async () => { await refreshLock(); await loadDevices(); })(); diff --git a/tests/test_voice.py b/tests/test_voice.py index 1c61fa0..d3af245 100644 --- a/tests/test_voice.py +++ b/tests/test_voice.py @@ -161,6 +161,29 @@ def test_deploy_voice_refuses_exclusive_absent_owner(tmp_path): assert adb.pushed == [] +def test_reenroll_regenerates_and_redeploys(tmp_path): + s = _store(tmp_path) + s.archive_voice_wav("paul", b"WAV", origin_serial="D1", source_wav_path=None, now=NOW) + s.store_voice_ovsp("paul", b"OLD-pollu", now=NOW) # artefact pollué (ancien cap) + s.set_voice_manifest("paul", scope="global", owner_name=None, consent_text=None, now=NOW) + s.add_deployment("paul", "D1", now=NOW) + adb = _Adb() + r = vo.reenroll_voice(adb, s, _Bridge(), "paul", now=NOW) + assert r["reenrolled"] and r["redeployed"] == ["D1"] + assert s.voice_ovsp_bytes("paul") == b"OVSP-bytes" # régénéré (≠ ancien) + assert adb.pushed[-1][1] == _os.path.join(_OVDIR, "paul.ovsp") + + +def test_reenroll_requires_archived_wav(tmp_path): + s = _store(tmp_path) + s.set_voice_manifest("ghost", scope="global", owner_name=None, consent_text=None, now=NOW) + try: + vo.reenroll_voice(_Adb(), s, _Bridge(), "ghost", now=NOW) + assert False, "aurait dû lever (WAV non archivé)" + except ValueError: + pass + + def test_undeploy_voice(tmp_path): s = _store(tmp_path) s.archive_voice_wav("paul", b"x", origin_serial="D1", source_wav_path=None, now=NOW)