feat(voice): ré-enrôlement (régénère .ovsp + re-déploie) — pour appliquer le fix §7

deploy_voice réutilise le .ovsp stocké → une voix enrôlée avec l'ancien cap garde son
artefact pollué. reenroll_voice : ré-enrôle depuis le WAV archivé (enroll §7) + re-pousse
sur toutes les tablettes déployées. API POST /voices/catalog/{id}/reenroll, bouton UI
(si WAV archivé). tests +2. 42/42.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
alf 2026-06-23 23:45:52 +02:00
parent 0bf399cb79
commit 2894771bf4
4 changed files with 69 additions and 1 deletions

View File

@ -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()) store.audit("voice_unlock", actor=_OPERATOR, target=voice_id, now=_now_ms())
return {"voice_id": voice_id, "locked_profile_id": None} 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 ------------------- # ---- vue par tablette : voix chargées + chargeables -------------------
@app.get("/api/voices/{serial}") @app.get("/api/voices/{serial}")
def voices_for_tablet(serial: str): def voices_for_tablet(serial: str):

View File

@ -126,6 +126,33 @@ def undeploy_voice(adb: Adb, store: Store, serial: str, voice_id: str, *,
return {"voice_id": voice_id, "deployed": False} 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 la voix est déployée. `deploy_voice` réutilise le `.ovsp`
stocké donc sans -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]: 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** """Vue d'une tablette : voix **chargées** (sur le device) et voix **chargeables**
(catalogue non déployé, filtré par exclusivité).""" (catalogue non déployé, filtré par exclusivité)."""

View File

@ -290,7 +290,8 @@ async function voiceView() {
<td class="muted">${(v.deployed_serials||[]).length?(v.deployed_serials).join(", "):"—"}</td> <td class="muted">${(v.deployed_serials||[]).length?(v.deployed_serials).join(", "):"—"}</td>
<td>${v.locked_profile_id <td>${v.locked_profile_id
? `<button onclick="unlockVoice('${v.voice_id}')">déverrouiller</button>` ? `<button onclick="unlockVoice('${v.voice_id}')">déverrouiller</button>`
: `<button onclick="lockVoice('${v.voice_id}')">verrouiller…</button>`}</td> : `<button onclick="lockVoice('${v.voice_id}')">verrouiller…</button>`}
${v.has_wav?`<button onclick="reenrollVoice('${v.voice_id}')" title="régénère le .ovsp (fix §7) + re-pousse">ré-enrôler</button>`:""}</td>
</tr>`).join("")} </tr>`).join("")}
</table>` : `<span class="muted">catalogue vide — enregistre des voix via l'app admin puis synchronise</span>`), "full"); </table>` : `<span class="muted">catalogue vide — enregistre des voix via l'app admin puis synchronise</span>`), "full");
@ -389,6 +390,17 @@ async function unlockVoice(vid) {
voiceView(); 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(); })(); (async () => { await refreshLock(); await loadDevices(); })();
</script> </script>
</body> </body>

View File

@ -161,6 +161,29 @@ def test_deploy_voice_refuses_exclusive_absent_owner(tmp_path):
assert adb.pushed == [] 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): def test_undeploy_voice(tmp_path):
s = _store(tmp_path) s = _store(tmp_path)
s.archive_voice_wav("paul", b"x", origin_serial="D1", source_wav_path=None, now=NOW) s.archive_voice_wav("paul", b"x", origin_serial="D1", source_wav_path=None, now=NOW)