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:
parent
0bf399cb79
commit
2894771bf4
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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é)."""
|
||||
|
|
|
|||
|
|
@ -290,7 +290,8 @@ async function voiceView() {
|
|||
<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>
|
||||
: `<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("")}
|
||||
</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();
|
||||
}
|
||||
|
||||
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(); })();
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue