refactor+ui: distinction nette Technique / Médical

Séparation des deux domaines produit, code ET interface.
- code : features/technical/ (voices, fleet) vs features/medical/ (prêt pour patients,
  conversations, rag, questionnaires). Imports cross-domaine en absolu (robustes aux
  déplacements). 42/42.
- UI : nav en 2 blocs distincts — 🔧 Technique (Flotte, Voix, Mises à jour, Config &
  moteur, Audit) et ⚕️ Médical/clinique (Patients & profils, Conversations, RAG
  thérapeutique, Questionnaires — nouvelle section). Accents de couleur par domaine.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
alf 2026-07-01 16:54:43 +02:00
parent 8d4ab9a07f
commit bf02f6ca5d
20 changed files with 35 additions and 30 deletions

View File

@ -22,10 +22,10 @@ from .core.adb import Adb
from .core.context import VERSION, Ctx from .core.context import VERSION, Ctx
from .core.operator_router import make_router as operator_router from .core.operator_router import make_router as operator_router
from .core.store import Store from .core.store import Store
from .features.fleet.router import make_router as fleet_router from .features.technical.fleet.router import make_router as fleet_router
from .features.voices.autosync import VoiceAutoSync from .features.technical.voices.autosync import VoiceAutoSync
from .features.voices.bridge import VoiceBridge from .features.technical.voices.bridge import VoiceBridge
from .features.voices.router import make_router as voices_router from .features.technical.voices.router import make_router as voices_router
_WEB_DIR = Path(__file__).resolve().parent / "web" _WEB_DIR = Path(__file__).resolve().parent / "web"
_STORE_PATH = Path(os.environ.get("KAZEIA_STORE", Path.cwd() / "data" / "central.db")) _STORE_PATH = Path(os.environ.get("KAZEIA_STORE", Path.cwd() / "data" / "central.db"))

View File

@ -6,7 +6,7 @@ from __future__ import annotations
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel from pydantic import BaseModel
from ...core.context import Ctx from kazeia_central.core.context import Ctx
from .service import device_overview, fan_out from .service import device_overview, fan_out

View File

@ -13,8 +13,8 @@ from __future__ import annotations
from typing import Any, Callable from typing import Any, Callable
from ...core.adb import Adb from kazeia_central.core.adb import Adb
from ...core.provider import ProviderClient from kazeia_central.core.provider import ProviderClient
def fan_out(adb: Adb, fn: Callable[[str], Any], *, def fan_out(adb: Adb, fn: Callable[[str], Any], *,

View File

@ -20,7 +20,7 @@ import posixpath
import tempfile import tempfile
from typing import Any from typing import Any
from ...core.adb import Adb from kazeia_central.core.adb import Adb
# getExternalFilesDir("voix") de com.kazeia.admin. /sdcard = /storage/emulated/0. # getExternalFilesDir("voix") de com.kazeia.admin. /sdcard = /storage/emulated/0.
ADMIN_VOIX_DIR = os.environ.get( ADMIN_VOIX_DIR = os.environ.get(

View File

@ -45,7 +45,7 @@ class VoiceBridge:
f"(provisionner ov_venv, ou définir OV_PYTHON)") f"(provisionner ov_venv, ou définir OV_PYTHON)")
env = dict(os.environ, PYTHONPATH=str(_REPO), PYTHONUNBUFFERED="1") env = dict(os.environ, PYTHONPATH=str(_REPO), PYTHONUNBUFFERED="1")
self._proc = subprocess.Popen( self._proc = subprocess.Popen(
[self.ov_python, "-m", "kazeia_central.features.voices.worker"], [self.ov_python, "-m", "kazeia_central.features.technical.voices.worker"],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None, # stderr → console stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=None, # stderr → console
text=True, env=env, bufsize=1, text=True, env=env, bufsize=1,
) )

View File

@ -18,9 +18,9 @@ import posixpath
import tempfile import tempfile
from typing import Any from typing import Any
from ...core.adb import Adb from kazeia_central.core.adb import Adb
from ...core.provider import ProviderClient from kazeia_central.core.provider import ProviderClient
from ...core.store import Store from kazeia_central.core.store import Store
from . import admin_ingest, transfer from . import admin_ingest, transfer
from .bridge import VoiceBridge from .bridge import VoiceBridge

View File

@ -6,7 +6,7 @@ from __future__ import annotations
from fastapi import APIRouter from fastapi import APIRouter
from pydantic import BaseModel from pydantic import BaseModel
from ...core.context import Ctx from kazeia_central.core.context import Ctx
from . import orchestrator as vo from . import orchestrator as vo

View File

@ -13,7 +13,7 @@ from __future__ import annotations
import posixpath import posixpath
from ...core.adb import Adb, AdbError from kazeia_central.core.adb import Adb, AdbError
# Le provider expose VOICE_WAV_DIR = "${MODELS_DIR}/../voix/voix" → ce marqueur # Le provider expose VOICE_WAV_DIR = "${MODELS_DIR}/../voix/voix" → ce marqueur
# permet d'extraire MODELS_DIR depuis un wav_path, donc le dossier voix OmniVoice cible. # permet d'extraire MODELS_DIR depuis un wav_path, donc le dossier voix OmniVoice cible.

View File

@ -1,7 +1,7 @@
"""Worker d'enrôlement vocal — process persistant sous ov_venv. """Worker d'enrôlement vocal — process persistant sous ov_venv.
Lancé par `voice/bridge.py` (côté API py3.14) via : Lancé par `voice/bridge.py` (côté API py3.14) via :
ov_venv/bin/python -m kazeia_central.features.voices.worker ov_venv/bin/python -m kazeia_central.features.technical.voices.worker
Garde le modèle OmniVoice (enrôlement + ASR de réf) **chaud**, et sert une file de Garde le modèle OmniVoice (enrôlement + ASR de réf) **chaud**, et sert une file de
jobs via un protocole JSON-lines sur stdin/stdout. jobs via un protocole JSON-lines sur stdin/stdout.

View File

@ -25,15 +25,17 @@ async function withBusy(btn, fn) { const o = btn.textContent; btn.disabled = tru
// ---- sections ------------------------------------------------------------- // ---- sections -------------------------------------------------------------
const SECTIONS = [ const SECTIONS = [
{ group: "🔧 Technique", domain: "tech" },
{ id: "flotte", ic: "🛰", label: "Flotte", render: renderFlotte }, { id: "flotte", ic: "🛰", label: "Flotte", render: renderFlotte },
{ id: "voix", ic: "🎙", label: "Voix", render: renderVoix }, { id: "voix", ic: "🎙", label: "Voix", render: renderVoix },
{ id: "updates", ic: "⬆", label: "Mises à jour", render: renderUpdates }, { id: "updates", ic: "⬆", label: "Mises à jour", render: renderUpdates },
{ id: "config", ic: "⚙", label: "Config & moteur", render: () => stub("Config & moteur", "Config sampling (température, presets…) et prompts moteur, avec push par tablette. Dépend des méthodes call() base64-JSON côté app patiente (PROVIDER_RPC_SPEC).") },
{ id: "audit", ic: "📋", label: "Audit", render: renderAudit }, { id: "audit", ic: "📋", label: "Audit", render: renderAudit },
{ sep: "Parité admin (à venir)" }, { group: "⚕️ Médical / clinique", domain: "med" },
{ id: "profils", ic: "👤", label: "Profils", render: () => stub("Profils & fiche patient", "Fiche clinique chiffrée côté Kazeia-central (identité, contact, clinique) + langue par patient. La fiche est constructible maintenant ; la propagation de la langue passe par l'app patiente (PROFILE_LANGUAGE_SPEC livrée).") }, { id: "patients", ic: "👤", label: "Patients & profils", render: () => stub("Patients & fiche patient", "Dossier patient chiffré côté Kazeia-central (identité, contact, clinique) + profils on-device + langue par patient. La fiche est constructible maintenant ; la propagation de la langue passe par l'app patiente (PROFILE_LANGUAGE_SPEC livrée).") },
{ id: "conversations", ic: "💬", label: "Conversations", render: () => stub("Conversations cliniques", "Rapatriement chiffré des conversations + export PDF/JSON + purge + collecteur de fond. Dépend de conversations_export côté app patiente.") }, { id: "conversations", ic: "💬", label: "Conversations", render: () => stub("Conversations cliniques", "Rapatriement chiffré des conversations + export PDF/JSON + purge + collecteur de fond. Cœur RGPD. Dépend de conversations_export côté app patiente.") },
{ id: "config", ic: "⚙", label: "Config & presets", render: () => stub("Config & presets", "Édition de la config sampling + presets et push. Dépend des méthodes call() base64-JSON côté app patiente (PROVIDER_RPC_SPEC).") }, { id: "rag", ic: "📚", label: "RAG thérapeutique", render: () => stub("Corpus RAG thérapeutique", "Authoring du corpus clinique + test retrieval + publication OTA. Dépend de rag_upsert_json côté app patiente.") },
{ id: "rag", ic: "📚", label: "RAG", render: () => stub("Corpus RAG", "Authoring du corpus + test retrieval + publication OTA. Dépend de rag_upsert_json côté app patiente.") }, { id: "questionnaires", ic: "📝", label: "Questionnaires", render: () => stub("Questionnaires cliniques", "Passation d'échelles/questionnaires cliniques au patient et suivi des scores dans le temps. À concevoir avec toi.") },
]; ];
// ---- boot + shell --------------------------------------------------------- // ---- boot + shell ---------------------------------------------------------
@ -73,8 +75,8 @@ async function renderShell() {
</div> </div>
<nav class="sidebar" id="sidebar"></nav> <nav class="sidebar" id="sidebar"></nav>
<main><div id="content"></div></main>`; <main><div id="content"></div></main>`;
$("#sidebar").innerHTML = SECTIONS.map(s => s.sep $("#sidebar").innerHTML = SECTIONS.map(s => s.group
? `<div class="nav-label">${s.sep}</div>` ? `<div class="nav-label ${s.domain === "med" ? "dom-med" : "dom-tech"}">${s.group}</div>`
: `<div class="nav-item" data-id="${s.id}"><span class="ic">${s.ic}</span>${s.label}</div>`).join(""); : `<div class="nav-item" data-id="${s.id}"><span class="ic">${s.ic}</span>${s.label}</div>`).join("");
$("#sidebar").querySelectorAll(".nav-item").forEach(n => n.onclick = () => { S.section = n.dataset.id; renderSection(); }); $("#sidebar").querySelectorAll(".nav-item").forEach(n => n.onclick = () => { S.section = n.dataset.id; renderSection(); });
await loadDevices(); await loadDevices();
@ -94,7 +96,7 @@ async function loadDevices() {
function renderSection() { function renderSection() {
document.querySelectorAll(".nav-item").forEach(n => n.classList.toggle("active", n.dataset.id === S.section)); document.querySelectorAll(".nav-item").forEach(n => n.classList.toggle("active", n.dataset.id === S.section));
const sec = SECTIONS.find(s => s.id === S.section) || SECTIONS[0]; const sec = SECTIONS.find(s => s.id === S.section) || SECTIONS.find(s => s.render);
const c = $("#content"); c.innerHTML = `<div class="empty"><span class="spinner"></span></div>`; const c = $("#content"); c.innerHTML = `<div class="empty"><span class="spinner"></span></div>`;
Promise.resolve(sec.render(c)).catch(e => { c.innerHTML = `<div class="card"><div class="err">${esc(e.message)}</div></div>`; }); Promise.resolve(sec.render(c)).catch(e => { c.innerHTML = `<div class="card"><div class="err">${esc(e.message)}</div></div>`; });
} }

View File

@ -64,7 +64,10 @@ h1,h2,h3 { margin: 0; font-weight: 650; }
.nav-item.active { background: var(--primary-soft); color: var(--primary-d); font-weight: 650; } .nav-item.active { background: var(--primary-soft); color: var(--primary-d); font-weight: 650; }
.nav-sep { height: 1px; background: var(--line); margin: 10px 6px; } .nav-sep { height: 1px; background: var(--line); margin: 10px 6px; }
.nav-label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); .nav-label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted);
padding: 6px 12px 4px; } padding: 12px 12px 5px; font-weight: 700; }
.nav-label.dom-tech { color: var(--primary-d); border-top: 1px solid var(--line); margin-top: 4px; }
.nav-label.dom-med { color: var(--ok); border-top: 1px solid var(--line); margin-top: 10px; }
.nav-label.dom-tech:first-child { border-top: 0; margin-top: 0; }
main { margin: var(--top) 0 0 var(--side); padding: 22px 26px; min-height: calc(100vh - var(--top)); } main { margin: var(--top) 0 0 var(--side); padding: 22px 26px; min-height: calc(100vh - var(--top)); }
.page-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; } .page-head { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }

View File

@ -6,7 +6,7 @@ from fastapi.testclient import TestClient
from kazeia_central.core.adb import Adb, AdbError from kazeia_central.core.adb import Adb, AdbError
from kazeia_central.core.adb.client import AdbDevice from kazeia_central.core.adb.client import AdbDevice
from kazeia_central.app import create_app from kazeia_central.app import create_app
from kazeia_central.features.fleet.service import device_overview, fan_out from kazeia_central.features.technical.fleet.service import device_overview, fan_out
from kazeia_central.core.store import Store from kazeia_central.core.store import Store

View File

@ -13,9 +13,9 @@ import nacl.pwhash
from kazeia_central.core.adb import Adb from kazeia_central.core.adb import Adb
from kazeia_central.core.adb.client import AdbDevice from kazeia_central.core.adb.client import AdbDevice
from kazeia_central.core.store import Store from kazeia_central.core.store import Store
from kazeia_central.features.voices import admin_ingest from kazeia_central.features.technical.voices import admin_ingest
from kazeia_central.features.voices import orchestrator as vo from kazeia_central.features.technical.voices import orchestrator as vo
from kazeia_central.features.voices import transfer from kazeia_central.features.technical.voices import transfer
_OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN _OPS = nacl.pwhash.argon2id.OPSLIMIT_MIN
_MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN _MEM = nacl.pwhash.argon2id.MEMLIMIT_MIN
@ -249,14 +249,14 @@ def test_sync_admin_delete_cleans(tmp_path):
# ---- déclencheur auto ----------------------------------------------------- # ---- déclencheur auto -----------------------------------------------------
def test_autosync_blocked_when_locked(tmp_path): def test_autosync_blocked_when_locked(tmp_path):
from kazeia_central.features.voices.autosync import VoiceAutoSync from kazeia_central.features.technical.voices.autosync import VoiceAutoSync
locked = Store(tmp_path / "l.db", kdf_ops=_OPS, kdf_mem=_MEM) # pas unlock locked = Store(tmp_path / "l.db", kdf_ops=_OPS, kdf_mem=_MEM) # pas unlock
a = VoiceAutoSync(_Adb([_manifest("paul", "global")]), locked, _Bridge()) a = VoiceAutoSync(_Adb([_manifest("paul", "global")]), locked, _Bridge())
assert a.sync_now("D1")["state"] == "store_verrouille" assert a.sync_now("D1")["state"] == "store_verrouille"
def test_autosync_enabled_ingests(tmp_path): def test_autosync_enabled_ingests(tmp_path):
from kazeia_central.features.voices.autosync import VoiceAutoSync from kazeia_central.features.technical.voices.autosync import VoiceAutoSync
s = _store(tmp_path) s = _store(tmp_path)
a = VoiceAutoSync(_Adb([_manifest("paul", "global")]), s, _Bridge()) a = VoiceAutoSync(_Adb([_manifest("paul", "global")]), s, _Bridge())
a.set_enabled(True, delete_source=False) a.set_enabled(True, delete_source=False)