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,
profils utilisateurs) par voix."""
rows = _provider_voices(adb, serial)
ov = transfer.omnivoice_dir_for(rows[0].wav_path) if rows else None
deployed = transfer.deployed_ovsp(adb, serial, ov) if ov else set()
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 = []
@ -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."""
data = _ensure_wav_archived(adb, store, serial, voice_id, now=now)
wav_path = _find_wav_path(adb, serial, voice_id)
ov = transfer.omnivoice_dir_for(wav_path) if wav_path else None
if not ov:
raise ValueError(f"dossier omnivoice indéterminé pour {voice_id}")
ov = _patient_omnivoice_dir(adb, serial)
with tempfile.TemporaryDirectory() as td:
local = os.path.join(td, f"{voice_id}.wav")

View File

@ -72,6 +72,7 @@
<span class="sub" id="sub">console de flotte</span>
<div class="right">
<span id="lockchip"></span>
<button onclick="voiceView()">🎙 Voix</button>
<button onclick="fleetView()">▦ Vue parc</button>
<button onclick="loadDevices()">↻ Parc</button>
</div>
@ -259,6 +260,109 @@ async function selectDevice(serial) {
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(); })();
</script>
</body>