feat(voice): UI panneau voix + fix dérivation dossier omnivoice

- web : bouton "🎙 Voix" + vue par tablette : déclencheur auto (armer/désarmer +
  delete_source + statut), voix admin à enrôler (nom/scope/propriétaire/statut/
  consentement + bouton "Synchroniser"), voix déployées (statut/verrou/profils +
  verrouiller/déverrouiller).
- fix : list_voices et enroll_deploy dérivaient le dossier omnivoice depuis
  /voices.wav_path (VIDE post-migration → chemin relatif → 502/échec). Passent
  désormais par _patient_omnivoice_dir (env → wav_path legacy → sonde MODELS_DIR).
- 46/46 ; vérifié live : /voices/{serial} et /admin répondent 200, UI servie.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
alf 2026-06-23 11:35:38 +02:00
parent 06b5cda214
commit 783bca1ba4
2 changed files with 109 additions and 5 deletions

View File

@ -66,8 +66,10 @@ 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, """Inventaire voix de la tablette croisé avec l'archive store (statut, verrou,
profils utilisateurs) par voix.""" profils utilisateurs) par voix."""
rows = _provider_voices(adb, serial) rows = _provider_voices(adb, serial)
ov = transfer.omnivoice_dir_for(rows[0].wav_path) if rows else None try:
deployed = transfer.deployed_ovsp(adb, serial, ov) if ov else set() 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 {} recs = {r["voice_id"]: r for r in store.voices(serial)} if store.is_unlocked else {}
used_by, _ = _profiles_using(adb, serial) used_by, _ = _profiles_using(adb, serial)
out = [] out = []
@ -159,9 +161,7 @@ def enroll_deploy(adb: Adb, store: Store, bridge: VoiceBridge, serial: str, voic
Le WAV reste archivé côté PC suppression device réversible par -enrôlement.""" Le WAV reste archivé côté PC suppression device réversible par -enrôlement."""
data = _ensure_wav_archived(adb, store, serial, voice_id, now=now) data = _ensure_wav_archived(adb, store, serial, voice_id, now=now)
wav_path = _find_wav_path(adb, serial, voice_id) wav_path = _find_wav_path(adb, serial, voice_id)
ov = transfer.omnivoice_dir_for(wav_path) if wav_path else None ov = _patient_omnivoice_dir(adb, serial)
if not ov:
raise ValueError(f"dossier omnivoice indéterminé pour {voice_id}")
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
local = os.path.join(td, f"{voice_id}.wav") local = os.path.join(td, f"{voice_id}.wav")

View File

@ -72,6 +72,7 @@
<span class="sub" id="sub">console de flotte</span> <span class="sub" id="sub">console de flotte</span>
<div class="right"> <div class="right">
<span id="lockchip"></span> <span id="lockchip"></span>
<button onclick="voiceView()">🎙 Voix</button>
<button onclick="fleetView()">▦ Vue parc</button> <button onclick="fleetView()">▦ Vue parc</button>
<button onclick="loadDevices()">↻ Parc</button> <button onclick="loadDevices()">↻ Parc</button>
</div> </div>
@ -259,6 +260,109 @@ 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) ---------------------------------
const SCOPE_COLOR = { exclusive:"var(--warn)", global:"var(--ok)", pending:"var(--muted)" };
function scopeBadge(s) {
return `<span class="badge" style="color:${SCOPE_COLOR[s]||"var(--muted)"};border-color:${SCOPE_COLOR[s]||"var(--line)"}">${s||"?"}</span>`;
}
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; }
const serial = selected;
const d = $("detail");
d.innerHTML = `<div class="muted">chargement des voix de <b>${serial}</b></div>`;
const [auto, admin, deployed] = await Promise.all([
api("/voices/autosync").catch(e=>({error:e.message})),
api(`/voices/${serial}/admin`).catch(e=>({error:e.message})),
api(`/voices/${serial}`).catch(e=>({error:e.message})),
]);
// 1) déclencheur auto
const aStat = auto?.devices?.[serial];
const autoCard = card("Déclencheur automatique",
auto.error ? `<div class="err">${auto.error}</div>` :
`<div class="kv"><span class="k">état</span><span class="v">${dot(auto.enabled)}${auto.enabled?"armé":"désarmé"}</span></div>
<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>
<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 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
const av = Array.isArray(admin) ? admin : [];
const adminCard = card(`Voix enregistrées — admin (${av.length})`,
admin.error ? `<div class="err">${admin.error}</div>` :
(av.length ? `<table>
<tr><th>nom</th><th>portée</th><th>propriétaire</th><th>statut</th><th>consentement</th></tr>
${av.map(v=>`<tr>
<td><b>${v.name||v.voice_id}</b><div class="serial">${v.voice_id}</div></td>
<td>${scopeBadge(v.scope)}</td>
<td>${v.owner_name||v.owner_profile_id||"—"}</td>
<td>${v.status}${v.deployed_on_device?" ✓":""}</td>
<td class="muted" title="${(v.reference_text||"").replace(/"/g,"&quot;")}">${(v.reference_text||"—").slice(0,40)}${(v.reference_text||"").length>40?"…":""}</td>
</tr>`).join("")}
</table>` : `<span class="muted">aucune voix enregistrée côté admin</span>`) +
`<div class="row" style="display:flex;gap:8px;align-items:center;margin-top:10px">
<button id="syncbtn" onclick="syncAdmin('${serial}')">Synchroniser (enrôler + déployer)</button>
<label class="muted" style="font-size:12px"><input type="checkbox" id="sync_del"> supprimer le WAV device après</label>
<span id="syncmsg" class="muted"></span>
</div>`, "full");
// 3) voix déployées (verrou)
const dv = Array.isArray(deployed) ? deployed : [];
const depCard = card(`Voix déployées sur la tablette (${dv.filter(v=>v.deployed_on_device).length})`,
deployed.error ? `<div class="err">${deployed.error}</div>` :
(dv.length ? `<table>
<tr><th>voix</th><th>statut</th><th>verrou</th><th>profils</th><th></th></tr>
${dv.map(v=>`<tr>
<td>${v.voice_id}</td>
<td>${v.deployed_on_device?dot(true)+"déployée":v.status}</td>
<td>${v.locked_profile_id?`🔒 ${v.locked_profile_id}`:"<span class='muted'>généraliste</span>"}</td>
<td class="muted">${(v.used_by_profiles||[]).join(", ")||"—"}</td>
<td>${v.locked_profile_id
? `<button onclick="unlockVoice('${serial}','${v.voice_id}')">déverrouiller</button>`
: `<button onclick="lockVoice('${serial}','${v.voice_id}')">verrouiller…</button>`}</td>
</tr>`).join("")}
</table>` : `<span class="muted">aucune voix</span>`), "full");
d.innerHTML = `<div class="grid">${autoCard}${adminCard}${depCard}</div>`;
}
async function toggleAutosync(on) {
const del = $("auto_del")?.checked || false;
await api("/voices/autosync", {method:"POST", headers:{"content-type":"application/json"},
body: JSON.stringify({enabled:on, delete_source:del})});
voiceView();
}
async function syncAdmin(serial) {
const del = $("sync_del")?.checked || false;
const btn = $("syncbtn"), msg = $("syncmsg");
btn.disabled = true; msg.textContent = "enrôlement en cours… (≈1 min par voix, ne ferme pas)";
try {
const rep = await api(`/voices/${serial}/admin/sync`, {method:"POST",
headers:{"content-type":"application/json"}, body: JSON.stringify({delete_source:del})});
const ok = rep.filter(r=>r.ok && r.deployed).length;
msg.textContent = `${ok}/${rep.length} voix déployée(s).`;
await voiceView();
} catch (e) { msg.textContent = "erreur : " + e.message; btn.disabled = false; }
}
async function lockVoice(serial, vid) {
const pid = prompt(`Verrouiller « ${vid} » sur quel profil patient ? (profile_id)`);
if (!pid) return;
await api(`/voices/${serial}/${vid}/lock`, {method:"POST",
headers:{"content-type":"application/json"}, body: JSON.stringify({profile_id:pid})});
voiceView();
}
async function unlockVoice(serial, vid) {
await api(`/voices/${serial}/${vid}/unlock`, {method:"POST"});
voiceView();
}
(async () => { await refreshLock(); await loadDevices(); })(); (async () => { await refreshLock(); await loadDevices(); })();
</script> </script>
</body> </body>