chore: socle Kazeia-central (étapes 0-1 lecture) + spec provider RPC
- CLAUDE.md fondateur (archi, contrat protocole §4, décisions figées) - docs/PROVIDER_RPC_SPEC.md : extensions call() base64-JSON à livrer à l'app patiente (dump/apply config·presets·profils·RAG, export conversations file-drop chiffré, handshake capabilities, token d'accès) — 100% additif, compat FROZEN - kazeia_central/adb : wrapper adb (découverte, content query/call, push/pull) - kazeia_central/provider : client typé pydantic des endpoints provider existants (lecture) ; écritures/texte riche en NotImplemented explicite (dépend du RPC) - kazeia_central/api : squelette FastAPI (lecture de flotte) - tests/ : parsing content query + coercion pydantic Repo autonome sibling de /opt/Kazeia et /opt/Kazeia-engine. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
311236cecc
|
|
@ -0,0 +1,17 @@
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# Données opérateur — JAMAIS commitées (PII clinique, secrets)
|
||||||
|
/data/
|
||||||
|
*.ndjson
|
||||||
|
*.ndjson.enc
|
||||||
|
*.sqlcipher
|
||||||
|
secrets.toml
|
||||||
|
.env
|
||||||
|
|
@ -0,0 +1,322 @@
|
||||||
|
# Kazeia-central — instructions projet
|
||||||
|
|
||||||
|
> Client de poste (PC/Mac) qui pilote les tablettes Kazeia branchées en USB :
|
||||||
|
> récupération des conversations cliniques, gestion du corpus RAG, et **parité
|
||||||
|
> complète avec Kazeia-admin** (config, presets, profils, voix, mises à jour).
|
||||||
|
>
|
||||||
|
> Ce fichier est le contrat de référence. Il prime sur toute supposition.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Mission et place dans l'écosystème Kazeia
|
||||||
|
|
||||||
|
Kazeia est un agent de soutien psychologique **on-device** tournant sur tablettes
|
||||||
|
Android (OnePlus Pad 3). L'écosystème comprend :
|
||||||
|
|
||||||
|
| Composant | Rôle | Repo |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **App patiente** `com.kazeia` | Pipeline STT→LLM→TTS, stocke conversations/profils/RAG/voix | `/opt/Kazeia/kazeia-android/app` |
|
||||||
|
| **App admin** `com.kazeia.admin` | Console de config **on-device**, parle au provider en local | `/opt/Kazeia/kazeia-android/app-admin` |
|
||||||
|
| **kazeia-engine** | Moteur natif (GGUF i8mm CPU + .pte QNN NPU), libs JNI | `/opt/Kazeia-engine` (repo séparé) |
|
||||||
|
| **Kazeia-central** | **Ce projet.** Console de poste qui pilote N tablettes via USB | `/opt/Kazeia-central` (repo séparé, **sibling** de `/opt/Kazeia` et `/opt/Kazeia-engine`) |
|
||||||
|
|
||||||
|
Kazeia-central **n'est pas un nouveau pipeline** : c'est une **télécommande de flotte**.
|
||||||
|
Il parle le **même protocole que Kazeia-admin** (le ContentProvider exporté
|
||||||
|
`com.kazeia.provider`), mais à distance via `adb` au lieu d'un `ContentResolver`
|
||||||
|
local. Tout ce que l'admin fait on-device, Kazeia-central doit pouvoir le faire
|
||||||
|
depuis le PC, sur plusieurs tablettes à la fois.
|
||||||
|
|
||||||
|
**Pourquoi un PC plutôt que l'app admin ?** Saisie clavier confortable pour
|
||||||
|
l'authoring RAG et les prompts, archivage clinique centralisé hors tablette,
|
||||||
|
gestion de flotte (plusieurs patients/tablettes), et accès à l'**encodeur de voix
|
||||||
|
Python x86** qui ne tourne que sur PC (cf. §6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Décisions d'architecture (figées)
|
||||||
|
|
||||||
|
1. **Stack : Python (FastAPI) + UI web locale.**
|
||||||
|
Daemon Python = cœur métier (wrapper adb, store chiffré, intégration encodeur
|
||||||
|
voix, publication WebDAV). UI = web servie en local (navigateur), wrappable en
|
||||||
|
app desktop via **pywebview**. Choix motivé par la réutilisation directe de
|
||||||
|
l'encodeur voix CosyVoice (déjà Python x86) et de l'expertise Python de
|
||||||
|
l'équipe (Kaz, beta_kazeia).
|
||||||
|
|
||||||
|
2. **Transport : ADB + RPC base64-JSON.**
|
||||||
|
Tout passe par le ContentProvider exporté `com.kazeia.provider` via
|
||||||
|
`adb shell content call/query/…`. Pour écrire des textes complexes (prompts
|
||||||
|
multilignes, `presets_json`, fiches RAG), on **ajoute au provider des méthodes
|
||||||
|
`call()` prenant un JSON encodé base64** (cf. §4.4) — `content --bind` casse sur
|
||||||
|
`:` et les espaces. **Pas de serveur réseau dans l'app patiente.** Fonctionne
|
||||||
|
sans root.
|
||||||
|
|
||||||
|
3. **Périmètre v1 : parité admin complète.**
|
||||||
|
Config + presets sampling, profils, conversations, RAG, voix, mises à jour —
|
||||||
|
tout Kazeia-admin, depuis le PC, en multi-tablette.
|
||||||
|
|
||||||
|
4. **Conversations cliniques chiffrées au repos sur le PC.**
|
||||||
|
Base locale chiffrée (SQLCipher ou âge/`age`), déverrouillée par mot de passe
|
||||||
|
opérateur. On **conserve le niveau de protection du device**. Rétention et purge
|
||||||
|
explicites. Contexte RGPD santé (Luxembourg).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Contraintes dures imposées par le code patient (ne pas les contourner)
|
||||||
|
|
||||||
|
1. **Le ContentProvider est l'unique surface de pilotage.**
|
||||||
|
Authority : `com.kazeia.provider`. `exported=true`, pas (encore) de permission
|
||||||
|
signature → joignable par l'uid `shell` via adb. C'est *exactement* ce que
|
||||||
|
l'app admin utilise en local.
|
||||||
|
|
||||||
|
2. **Les conversations sont chiffrées au repos sur la tablette** (SQLCipher,
|
||||||
|
passphrase dans `SecureKeyStore`). Conséquence : `adb pull conversations.db`
|
||||||
|
donne un fichier **illisible**. **Les conversations ne se récupèrent QUE via le
|
||||||
|
provider** (`/conversations`, `/conversations/session/{id}`), qui les déchiffre
|
||||||
|
et les sert. Aucun raccourci par le filesystem.
|
||||||
|
|
||||||
|
3. **`/data/data/com.kazeia/` est inaccessible en adb** sur device de prod
|
||||||
|
non-rooté. Donc : config/profils/voix/RAG/updates → **provider** ; gros fichiers
|
||||||
|
(WAV de voix, modèles) → `adb push/pull` vers le **stockage externe**
|
||||||
|
`Android/data/com.kazeia/files/`.
|
||||||
|
|
||||||
|
4. **La sortie texte d'`adb shell content` est cassée pour le texte riche, dans
|
||||||
|
les DEUX sens.** En écriture, `content --bind` mange `:` et les espaces. En
|
||||||
|
lecture, `content query` rend du `Row: 0 col=val, col=val` : dès qu'une valeur
|
||||||
|
contient un retour à la ligne ou un `:` (ex. `speaker_system_prompt`,
|
||||||
|
multiligne), le parsing est corrompu. → **Toute I/O de texte non trivial passe
|
||||||
|
par le RPC base64-JSON (§4.4)**, jamais par `--bind`/`query` brut.
|
||||||
|
|
||||||
|
5. **`am startservice` est throttlé sans réveil du provider** ; faire un `content
|
||||||
|
query`/`call` *avant* pour réveiller le process. (Gotchas test device.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Contrat de protocole (le « wire »)
|
||||||
|
|
||||||
|
Authority : `content://com.kazeia.provider`. Pilotage via
|
||||||
|
`adb shell content {query|update|insert|delete|call} --uri … [--bind …] [--method …]`.
|
||||||
|
|
||||||
|
> ⚠️ La source de vérité reste le code : `KazeiaTelemetryProvider.kt` (patient) et
|
||||||
|
> les clients `app-admin/.../data/source/Kazeia*Client.kt`. Ce qui suit en est le
|
||||||
|
> reflet à jour ; re-vérifier avant d'implémenter un endpoint.
|
||||||
|
|
||||||
|
### 4.1 Lecture (`content query`)
|
||||||
|
| URI | Colonnes clés |
|
||||||
|
| --- | --- |
|
||||||
|
| `/state` | `pid, uptime_seconds, pss_mb, ion_mb, …` + **handshake à ajouter** : `app_version_code, provider_schema, supported_calls` (cf. §4.4) |
|
||||||
|
| `/turns` | télémétrie pipeline (timings STT/LLM/TTS par tour) |
|
||||||
|
| `/crashes` | `timestamp, component, message` |
|
||||||
|
| `/config` | config complète (cf. §4.5) |
|
||||||
|
| `/models` | `id, display_name, pte_path, tokenizer_path, role, size_mb, max_seq_len, file_exists, notes` |
|
||||||
|
| `/profiles`, `/profiles/active`, `/profiles/{id}` | profils patients |
|
||||||
|
| `/conversations` | sessions : `profile_id, session_id, started_at, ended_at, turn_count` |
|
||||||
|
| `/conversations/profile/{id}` | sessions d'un profil |
|
||||||
|
| `/conversations/session/{id}` | tours : `id, profile_id, session_id, timestamp, role(PATIENT\|KAZEIA), text, ttft_ms, total_ms, voice_used, model_used` |
|
||||||
|
| `/dist_config` | `webdav_base, webdav_user, has_password, is_customized` (jamais le pass en clair) |
|
||||||
|
| `/rag`, `/rag/{source}` | corpus : index + texte d'un doc |
|
||||||
|
| `/rag_status` | `enabled, ready, model, dim, doc_count, chunk_count, pending` |
|
||||||
|
| `/rag_query` (selectionArgs[0] = requête) | `source, score, text` (test retrieval) |
|
||||||
|
| `/updates` | `phase, app_update_available, app_version_name, app_size, content_count, content_size, progress_done, progress_total, label, error, last_check` |
|
||||||
|
| `/voices` | `id, name, state, wav_exists, prefix_exists, suffix_exists, wav_size_bytes, wav_duration_seconds, wav_path, created_at` |
|
||||||
|
|
||||||
|
### 4.2 Mutation (`content update/insert/delete`) — champs simples
|
||||||
|
- `update /config` : merge partiel (cf. §4.5). Diffuse `com.kazeia.action.RELOAD_CONFIG`.
|
||||||
|
- `update /profiles[/{id}]`, `/profiles/active` ; `delete /profiles/{id}` (cascade conv.). Diffuse `RELOAD_PROFILES`.
|
||||||
|
- `delete /conversations/session/{id}` et `/conversations/profile/{id}`.
|
||||||
|
- `update /dist_config` : `webdav_base, webdav_user, webdav_pass, reset`.
|
||||||
|
- `insert /rag` (`source`,`text`) ; `delete /rag/{source}`. Diffuse `RELOAD_RAG`.
|
||||||
|
- `delete /voices/{id}` (wav + 2 embeddings). Diffuse `RELOAD_VOICES`.
|
||||||
|
|
||||||
|
### 4.3 RPC existant (`content call`)
|
||||||
|
- `update_check` → `{accepted:true}`, lance `UpdateWorker(mode=check)`. Poller `/updates`.
|
||||||
|
- `update_install` → `{accepted:true}`, lance `UpdateWorker(mode=install)`. Poller `/updates`.
|
||||||
|
|
||||||
|
### 4.4 RPC base64-JSON — À AJOUTER côté app patiente (modif provider)
|
||||||
|
Motivation : §3.4 — l'I/O texte d'`adb content` est cassée **dans les deux sens**.
|
||||||
|
La modif côté Kazeia est **additive** (nouvelles méthodes `call()`, aucun
|
||||||
|
comportement existant changé → compatible `FROZEN.md`). C'est **le livrable n°1**,
|
||||||
|
prérequis bloquant des étapes 3-6 de la roadmap. Forme cible :
|
||||||
|
|
||||||
|
```
|
||||||
|
# écriture
|
||||||
|
adb shell content call --uri content://com.kazeia.provider \
|
||||||
|
--method cfg_apply_json --arg <base64(JSON)>
|
||||||
|
# lecture (résultat = base64(JSON) dans le Bundle de retour)
|
||||||
|
adb shell content call --uri content://com.kazeia.provider --method cfg_dump_json
|
||||||
|
```
|
||||||
|
|
||||||
|
Le `arg` et le retour sont du **base64 d'un objet JSON** (zéro caractère spécial →
|
||||||
|
survit au shell ET au parsing). Méthodes à spécifier/implémenter :
|
||||||
|
|
||||||
|
**Lecture (dump)** — remplace `query` pour tout champ à texte riche :
|
||||||
|
- `cfg_dump_json` — config complète (prompts multilignes inclus).
|
||||||
|
- `presets_dump_json` — `presets_json` complet.
|
||||||
|
- `profile_dump_json` (arg = id) — profil complet (prompt override inclus).
|
||||||
|
|
||||||
|
**Écriture (apply/upsert)** — remplace `--bind` :
|
||||||
|
- `cfg_apply_json` — patch partiel de config (mêmes clés que §4.5), prompts inclus.
|
||||||
|
- `presets_apply_json` — remplace/merge `presets_json`.
|
||||||
|
- `rag_upsert_json` — `{source, text}` (fiche multiligne).
|
||||||
|
- `profile_upsert_json` — profil complet (prompt override inclus).
|
||||||
|
|
||||||
|
**Export conversations (file-drop)** — PAS via Bundle (limite binder ~1 Mo →
|
||||||
|
`TransactionTooLargeException` sur un historique réel) :
|
||||||
|
- `conversations_export` (arg = base64 d'un filtre `{profile_id?, since?, until?}`)
|
||||||
|
→ le provider **écrit un NDJSON (idéalement chiffré)** dans
|
||||||
|
`Android/data/com.kazeia/files/exports/` et renvoie le chemin ; le PC fait
|
||||||
|
`adb pull`. C'est le chemin clinique fiable (volume + parsing + chiffrement).
|
||||||
|
|
||||||
|
**Handshake** — pour piloter une flotte hétérogène :
|
||||||
|
- ajouter à `/state` (ou méthode `capabilities`) : `app_version_code`,
|
||||||
|
`provider_schema`, `supported_calls` (liste des méthodes présentes). Kazeia-central
|
||||||
|
s'adapte à la version de chaque tablette au lieu de planter.
|
||||||
|
|
||||||
|
**Contrôle d'accès (à spécifier maintenant, activer plus tard)** — le provider est
|
||||||
|
`exported=true` **sans permission** : toute app sideloadée lit les conversations.
|
||||||
|
Une `signature permission` casserait l'uid `shell` (donc Kazeia-central) ET l'app
|
||||||
|
admin → **mauvaise piste**. Retenir un **secret partagé** : token provisionné une
|
||||||
|
fois, stocké en `EncryptedSharedPreferences`, passé dans les `extras` du `call()` et
|
||||||
|
vérifié par le provider sur les endpoints sensibles. Préserve l'accès adb.
|
||||||
|
|
||||||
|
**Confort authoring local (nice-to-have)** :
|
||||||
|
- `rag_sync`, `voices_reload` — déclenchent un re-scan après un `adb push` direct de
|
||||||
|
fichiers, pour éviter l'aller-retour WebDAV en dev mono-tablette.
|
||||||
|
|
||||||
|
> **À faire en premier dans ce repo** : rédiger `docs/PROVIDER_RPC_SPEC.md` (signatures,
|
||||||
|
> schémas JSON, token), à livrer au dev de l'app patiente — même démarche que
|
||||||
|
> `SAMPLING_ENGINE_SPEC.md` côté kazeia-android.
|
||||||
|
> Tant que ces méthodes n'existent pas, fallback : `adb push` d'un fichier temp +
|
||||||
|
> `call` avec le chemin. **Ne jamais bricoler `--bind`/`query` pour du texte riche.**
|
||||||
|
|
||||||
|
### 4.5 Schéma config (`/config`, miroir de `ConfigStore.kt`)
|
||||||
|
- Cascade/TTS : `cascade_enabled`, `tts_enabled`, `stt_engine`, `llm_engine`, `tts_engine`
|
||||||
|
(prod actuelle : `tts_engine="cosyvoice"`, seul TTS ; `llm_engine`/`stt_engine` vestigiaux).
|
||||||
|
- RAG : `rag_enabled`, `rag_threshold` (déf. 0.82), `rag_top_k` (déf. 3).
|
||||||
|
- Debug : `debug_enabled` (masque le bouton logs/métriques côté patient).
|
||||||
|
- Speaker : `speaker_model_id, speaker_system_prompt, speaker_temperature, speaker_top_p,
|
||||||
|
speaker_top_k, speaker_repeat_penalty, speaker_presence_penalty, speaker_frequency_penalty,
|
||||||
|
speaker_max_tokens, speaker_preset_name`.
|
||||||
|
- Thinker : `thinker_*` (mêmes champs).
|
||||||
|
- Presets : `presets_json` = tableau `{name, temperature, top_p, top_k, repeat_penalty,
|
||||||
|
presence_penalty, frequency_penalty, max_tokens}`.
|
||||||
|
|
||||||
|
> ⚠️ Le moteur natif n'honore aujourd'hui que `max_tokens` côté sampling
|
||||||
|
> (cf. `docs/SAMPLING_ENGINE_SPEC.md` de kazeia-android). Kazeia-central pousse
|
||||||
|
> tous les champs ; leur effet réel dépend de l'avancement du moteur.
|
||||||
|
|
||||||
|
### 4.6 Transfert de fichiers (hors provider)
|
||||||
|
- **Stockage externe** (push/pull OK) : `Android/data/com.kazeia/files/kazeia/…`
|
||||||
|
- `models/` (LLM/STT/TTS), `voix/voix/{id}.wav`, `qwen3-tts-npu/{id}_voice_{prefix,suffix}.bin`, `rag_corpus/*.{txt,md}`.
|
||||||
|
- **Bases SQLite** : `kazeia_rag.db` est en **clair** (pullable pour inspection) ;
|
||||||
|
`conversations.db` est **chiffré** (inutile à puller — passer par le provider).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. OTA / publication (WebDAV)
|
||||||
|
- Catalogue modèles : `catalog.json` ; voix : `voices_catalog.json` ; sur Nextcloud
|
||||||
|
`box.kazeia.com/.../soft/`. Schéma : `{schema, catalog_version, min_app_version_code,
|
||||||
|
app{version_code,version_name,remote,size,sha256}, components[{id,label,version,
|
||||||
|
mandatory,files[{remote,local,size,sha256}]}]}`.
|
||||||
|
- Génération : script type `make_catalog.py` (calcule taille + sha256). Kazeia-central
|
||||||
|
doit pouvoir **éditer le corpus localement → publier sur WebDAV → bump
|
||||||
|
`catalog_version`**, puis déclencher `update_check`/`update_install` par tablette.
|
||||||
|
- Compte service WebDAV stocké côté app (`/dist_config`), modifiable à distance.
|
||||||
|
- L'install OTA est **manuelle** (boutons), jamais automatique au lancement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Intégration encodeur de voix (atout PC)
|
||||||
|
Workflow voix : **capture** WAV sur tablette → **extraction** des embeddings sur PC
|
||||||
|
(encodeur CosyVoice/Qwen3-TTS x86, Python) → **sync** des `.bin`/`.cvps` vers N
|
||||||
|
tablettes. Kazeia-central orchestre les 3 étapes :
|
||||||
|
1. `adb pull` du WAV depuis `…/files/kazeia/voix/voix/`.
|
||||||
|
2. Appel **in-process** de l'encodeur Python (raison n°1 du choix Python).
|
||||||
|
3. `adb push` des embeddings + mise à jour du catalogue voix.
|
||||||
|
|
||||||
|
État machine voix : `recorded → processing → ready/error` (refléter dans l'UI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Organisation du repo (proposée)
|
||||||
|
|
||||||
|
```
|
||||||
|
kazeia-central/
|
||||||
|
CLAUDE.md # ce fichier
|
||||||
|
pyproject.toml # FastAPI, uvicorn, pywebview, sqlcipher/age, httpx, pydantic
|
||||||
|
kazeia_central/
|
||||||
|
adb/ # wrapper adb : device discovery, content call/query, push/pull
|
||||||
|
provider/ # client typé du wire (§4) — pydantic models par endpoint
|
||||||
|
store/ # base chiffrée locale (conversations archivées, audit)
|
||||||
|
voice/ # bridge encodeur Python CosyVoice
|
||||||
|
ota/ # édition corpus, make_catalog, push WebDAV
|
||||||
|
api/ # routes FastAPI consommées par l'UI
|
||||||
|
web/ # UI (templates/SPA) servie en local
|
||||||
|
docs/
|
||||||
|
PROVIDER_RPC_SPEC.md # spec base64-JSON à livrer au dev app patiente (§4.4)
|
||||||
|
tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Repo git autonome à `/opt/Kazeia-central`** — un **sibling** de `/opt/Kazeia` et
|
||||||
|
`/opt/Kazeia-engine`, PAS un sous-dossier de `/opt/Kazeia`. Raison : le `.gitignore`
|
||||||
|
whitelist du repo `/opt/Kazeia` ne suit que `kazeia-android/` ; placer Kazeia-central
|
||||||
|
*dans* `/opt/Kazeia` l'exposerait à ce piège (et un `git clean` y est dangereux). En
|
||||||
|
sibling, le risque disparaît par construction, exactement comme kazeia-engine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Sécurité & conformité (RGPD santé)
|
||||||
|
- Conversations rapatriées = **PII de santé**. Au repos sur PC : **chiffrées**
|
||||||
|
(SQLCipher/age), clé dérivée d'un mot de passe opérateur, jamais en clair sur disque.
|
||||||
|
- Journaliser les accès (qui a exporté quoi, quand) — piste d'audit.
|
||||||
|
- Rétention/purge explicites et configurables ; export PDF/JSON sur action explicite.
|
||||||
|
- Le mot de passe WebDAV ne transite jamais en clair via `/dist_config` (le provider
|
||||||
|
ne le renvoie pas) — ne pas le logger.
|
||||||
|
- Le PC devient le point de concentration clinique : disque chiffré OS **en plus** du
|
||||||
|
store applicatif chiffré.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Multi-tablette (flotte)
|
||||||
|
- Découverte par `adb devices -l` ; identifier chaque tablette (serial → label patient).
|
||||||
|
- Toute action ciblée `-s <serial>`. Opérations de flotte (push corpus, bump OTA) =
|
||||||
|
fan-out séquentiel par device, avec rapport par tablette (le harness de test montre
|
||||||
|
que les commandes adb échouent silencieusement par device → toujours vérifier le retour).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Préférences de travail (Richard)
|
||||||
|
- Répondre en **français**. Code/identifiants dans leur forme d'origine.
|
||||||
|
- Quand un choix est délégué : **une** recommandation défendue, pas un menu.
|
||||||
|
- Pas d'empilement de questions ; faire l'hypothèse raisonnable, l'énoncer, avancer.
|
||||||
|
- Honnêteté technique : valider/rejeter avec arguments concrets, pas de complaisance.
|
||||||
|
- **Local-first / auto-hébergé** par défaut (pas de SaaS).
|
||||||
|
- Vérifier en ligne ce qui dépend d'une version/API récente plutôt que deviner.
|
||||||
|
- Commits : ne committer que sur demande ; finir par
|
||||||
|
`Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.
|
||||||
|
|
||||||
|
## 11. Intangibles (ne JAMAIS toucher)
|
||||||
|
- `/opt/Kazeia/beta_kazeia/` — référence comportementale FR thérapeutique.
|
||||||
|
- `/opt/Kazeia/root_oneplus/` — firmware/Magisk.
|
||||||
|
- `/opt/Kazeia/Kaz/` — chatbot RAG Python du binôme (à *lire* pour s'inspirer du RAG
|
||||||
|
et des garde-fous, jamais à modifier).
|
||||||
|
- `/opt/Kazeia/keystore/` — clé de signature (critique).
|
||||||
|
- Stack patiente **figée** (`/opt/Kazeia/FROZEN.md`) : toute modif du provider (§4.4)
|
||||||
|
passe par une spec livrée au dev, pas par une réécriture sauvage.
|
||||||
|
- Jamais de `git clean` dans `/opt/Kazeia` (effacerait les dossiers non suivis).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Roadmap indicative (v1 = parité admin)
|
||||||
|
0. **Socle** : wrapper adb + client provider typé + découverte device + store chiffré.
|
||||||
|
1. **Lecture** : dashboard flotte (state/turns/crashes/config/models/updates).
|
||||||
|
2. **Conversations** : pull via provider → archive chiffrée → export PDF/JSON + purge.
|
||||||
|
3. **Config & presets** : édition + push (via RPC base64-JSON §4.4).
|
||||||
|
4. **Profils** : CRUD + profil actif.
|
||||||
|
5. **RAG** : authoring corpus, test retrieval (`/rag_query`), publication OTA.
|
||||||
|
6. **Voix** : capture→encodage PC→sync flotte.
|
||||||
|
7. **Updates** : check/install OTA piloté, bump catalogue.
|
||||||
|
|
||||||
|
> Prérequis bloquant des étapes 2-6 : les méthodes `call()` base64-JSON + l'export
|
||||||
|
> conversations côté app patiente (§4.4). Rédiger `docs/PROVIDER_RPC_SPEC.md` en
|
||||||
|
> premier, le livrer au dev kazeia-android, puis démarrer le socle Python en
|
||||||
|
> parallèle (l'étape 0-1, lecture, ne dépend que des endpoints existants).
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Kazeia-central
|
||||||
|
|
||||||
|
Console de poste (PC/Mac) qui pilote les tablettes Kazeia branchées en USB :
|
||||||
|
récupération des conversations cliniques, gestion du corpus RAG, et parité avec
|
||||||
|
Kazeia-admin (config, presets, profils, voix, mises à jour).
|
||||||
|
|
||||||
|
> Architecture, contrat de protocole et décisions : voir **`CLAUDE.md`**.
|
||||||
|
> Spec des extensions provider à livrer à l'app patiente : **`docs/PROVIDER_RPC_SPEC.md`**.
|
||||||
|
|
||||||
|
## État
|
||||||
|
|
||||||
|
Socle (étapes 0-1, **lecture** de flotte) en place :
|
||||||
|
- `kazeia_central/adb/` — wrapper adb (découverte, `content query/call`, push/pull).
|
||||||
|
- `kazeia_central/provider/` — client typé (pydantic) des endpoints provider existants.
|
||||||
|
- `kazeia_central/api/` — API FastAPI + (à venir) UI web locale.
|
||||||
|
|
||||||
|
Les écritures (config/presets/profils/RAG) et l'export des conversations attendent
|
||||||
|
les méthodes `call()` base64-JSON côté app patiente (cf. `docs/PROVIDER_RPC_SPEC.md`).
|
||||||
|
|
||||||
|
## Démarrer
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv && . .venv/bin/activate
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
pytest -q # tests unitaires (parsing)
|
||||||
|
uvicorn kazeia_central.api.app:app --reload # API sur http://127.0.0.1:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Prérequis : `adb` dans le PATH, tablette en débogage USB autorisé.
|
||||||
|
|
||||||
|
## Repo
|
||||||
|
|
||||||
|
`/opt/Kazeia-central` — **sibling** de `/opt/Kazeia` et `/opt/Kazeia-engine`, repo git
|
||||||
|
autonome (ne PAS placer sous `/opt/Kazeia`, cf. CLAUDE.md §7).
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
# PROVIDER_RPC_SPEC — extensions `call()` du provider Kazeia pour Kazeia-central
|
||||||
|
|
||||||
|
> **Destinataire : dev de l'app patiente (`com.kazeia`, `/opt/Kazeia/kazeia-android`).**
|
||||||
|
> **Auteur : équipe Kazeia-central.** Démarche identique à `docs/SAMPLING_ENGINE_SPEC.md`.
|
||||||
|
>
|
||||||
|
> Objet : ajouter au `ContentProvider` `KazeiaTelemetryProvider` un jeu de méthodes
|
||||||
|
> `call()` qui transportent du **JSON encodé base64**, pour permettre à Kazeia-central
|
||||||
|
> (poste PC, via `adb shell content call`) de lire/écrire la config, les presets, les
|
||||||
|
> profils, le RAG, et d'exporter les conversations — choses aujourd'hui impossibles à
|
||||||
|
> faire de façon fiable via `content query`/`--bind`.
|
||||||
|
>
|
||||||
|
> **100 % additif** : aucune méthode/URI/colonne existante n'est modifiée. Compatible
|
||||||
|
> `FROZEN.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Pourquoi (le problème à régler)
|
||||||
|
|
||||||
|
La sortie texte d'`adb shell content` est cassée pour le texte riche, **dans les deux
|
||||||
|
sens** :
|
||||||
|
|
||||||
|
- **Écriture** — `content update --bind k:s:val` casse dès que `val` contient `:` ou un
|
||||||
|
espace (gotcha confirmé). Or `speaker_system_prompt` est multiligne et contient `:`.
|
||||||
|
- **Lecture** — `content query` rend `Row: 0 col=val, col=val`. Dès qu'une valeur
|
||||||
|
contient un retour à la ligne ou `, ` (cas des prompts et du texte des tours de
|
||||||
|
conversation), le parsing est corrompu, sans échappement possible.
|
||||||
|
|
||||||
|
**Solution** : des méthodes `call()` où l'argument **et** le retour sont du
|
||||||
|
**base64(UTF-8 JSON)**. Le base64 ne contient ni espace, ni `:`, ni saut de ligne →
|
||||||
|
survit au shell `adb` et à tout parsing. Pour les conversations (volume + limite de
|
||||||
|
transaction binder ~1 Mo → `TransactionTooLargeException`), on passe par un
|
||||||
|
**fichier déposé** (`adb pull`), pas par le `Bundle` de retour.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Convention générale (à respecter pour TOUTES les méthodes ci-dessous)
|
||||||
|
|
||||||
|
### 1.1 Entrée
|
||||||
|
```
|
||||||
|
adb shell content call \
|
||||||
|
--uri content://com.kazeia.provider \
|
||||||
|
--method <nom> \
|
||||||
|
--arg <BASE64(JSON)> \
|
||||||
|
[--extra token:s:<hex>]
|
||||||
|
```
|
||||||
|
- `arg` = `Base64.encodeToString(json.toByteArray(UTF_8), Base64.NO_WRAP)`, ou absent si la méthode n'a pas d'entrée.
|
||||||
|
- `extras.token` (String) = secret partagé optionnel (cf. §6). Hex pur → sûr en shell.
|
||||||
|
|
||||||
|
> Note : `ContentProvider.call(method, arg, extras)` reçoit l'`--arg` dans `arg` et
|
||||||
|
> chaque `--extra` dans `extras`. C'est la même signature que les `update_check`/
|
||||||
|
> `update_install` déjà en place.
|
||||||
|
|
||||||
|
### 1.2 Sortie (`Bundle` de retour)
|
||||||
|
Clés standardisées :
|
||||||
|
| clé | type | présence |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ok` | `boolean` | toujours |
|
||||||
|
| `error` | `String` | si `ok=false` (code court : `bad_arg`, `unauthorized`, `not_found`, `internal`) |
|
||||||
|
| `result_b64` | `String` | méthodes de lecture (= `base64(JSON)`) |
|
||||||
|
| `path` | `String` | `conversations_export` (chemin du fichier déposé) |
|
||||||
|
| `count` | `int` | méthodes qui agissent sur N éléments |
|
||||||
|
|
||||||
|
Helper côté provider suggéré :
|
||||||
|
```kotlin
|
||||||
|
private fun ok(extra: Bundle.() -> Unit = {}) =
|
||||||
|
Bundle().apply { putBoolean("ok", true); extra() }
|
||||||
|
private fun err(code: String) =
|
||||||
|
Bundle().apply { putBoolean("ok", false); putString("error", code) }
|
||||||
|
|
||||||
|
private fun decode(arg: String?): JSONObject? =
|
||||||
|
arg?.let { runCatching { JSONObject(String(Base64.decode(it, Base64.DEFAULT), Charsets.UTF_8)) }.getOrNull() }
|
||||||
|
private fun encode(obj: Any): String =
|
||||||
|
Base64.encodeToString(obj.toString().toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Point d'insertion
|
||||||
|
Tout se branche dans le `when (method)` existant de
|
||||||
|
`KazeiaTelemetryProvider.call()` (actuellement lignes ~159-175), après les deux
|
||||||
|
`CALL_UPDATE_*`. Déclarer les nouveaux noms en `companion object` (à côté de
|
||||||
|
`CALL_UPDATE_CHECK`). Le `else -> return null` final reste le fallback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Lecture (dump) — remplace `content query` pour le texte riche
|
||||||
|
|
||||||
|
### 2.1 `cfg_dump_json`
|
||||||
|
- **arg** : aucun.
|
||||||
|
- **retour** : `result_b64` = base64 de l'objet config complet (forme §5).
|
||||||
|
- **impl** : sérialiser `ConfigStore.get(ctx).current()` dans le schéma §5. Réutilise
|
||||||
|
les mêmes champs que `configCursor()` (lignes ~300-333) mais en JSON typé/imbriqué.
|
||||||
|
|
||||||
|
### 2.2 `presets_dump_json`
|
||||||
|
- **arg** : aucun.
|
||||||
|
- **retour** : `result_b64` = base64 d'un **tableau** de presets (forme §5.3).
|
||||||
|
- **impl** : exactement le JSON déjà produit par `presetsToJson()` (lignes ~336-349).
|
||||||
|
|
||||||
|
### 2.3 `profile_dump_json`
|
||||||
|
- **arg** : `{"id": "<profileId>"}`.
|
||||||
|
- **retour** : `result_b64` = base64 du profil (toutes colonnes de `/profiles`, dont
|
||||||
|
`system_prompt_override` multiligne). `ok=false, error=not_found` si l'id n'existe pas.
|
||||||
|
|
||||||
|
> Les autres lectures (`/state`, `/turns`, `/crashes`, `/models`, `/voices`,
|
||||||
|
> `/rag_status`, `/updates`, `/dist_config`, liste des sessions `/conversations`)
|
||||||
|
> **restent en `query`** : leurs valeurs sont numériques ou des chaînes courtes sans
|
||||||
|
> `:`/saut de ligne. Pas de dump nécessaire.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Écriture (apply / upsert) — remplace `--bind`
|
||||||
|
|
||||||
|
Toutes diffusent le broadcast de reload approprié (déjà définis :
|
||||||
|
`ACTION_RELOAD_CONFIG`, `ACTION_RELOAD_PROFILES`, `ACTION_RELOAD_RAG`).
|
||||||
|
|
||||||
|
### 3.1 `cfg_apply_json`
|
||||||
|
- **arg** : patch **partiel** de config (forme §5, toute clé absente = inchangée).
|
||||||
|
- **effet** : même sémantique de merge que `updateConfig()` (lignes ~423-449) et
|
||||||
|
`mergeModel()` (~453+). Réutiliser ces helpers ; ne pas dupliquer la logique.
|
||||||
|
Diffuser `ACTION_RELOAD_CONFIG` + `notifyChange`.
|
||||||
|
- **retour** : `ok`.
|
||||||
|
|
||||||
|
### 3.2 `presets_apply_json`
|
||||||
|
- **arg** : `{"presets": [ ... ]}` (forme §5.3) — **remplace** la bibliothèque entière.
|
||||||
|
- **effet** : `presetsFromJson()` existant, `store.save(...)`, broadcast `RELOAD_CONFIG`.
|
||||||
|
- **retour** : `ok`, `count` = nb de presets enregistrés.
|
||||||
|
|
||||||
|
### 3.3 `profile_upsert_json`
|
||||||
|
- **arg** : profil complet (mêmes clés que l'`upsertProfile()` actuel : `id?`,
|
||||||
|
`display_name`, `avatar_color`, `voice_id`, `system_prompt_override`, `pin?`,
|
||||||
|
`notes`, `is_default`). `id` absent → création.
|
||||||
|
- **effet** : réutiliser `upsertProfile(ContentValues)` en construisant le
|
||||||
|
`ContentValues` depuis le JSON. Broadcast `RELOAD_PROFILES`.
|
||||||
|
- **retour** : `ok`, et `result_b64` = base64 `{"id": "<id résultant>"}`.
|
||||||
|
|
||||||
|
### 3.4 `rag_upsert_json`
|
||||||
|
- **arg** : `{"source": "<id>", "text": "<fiche multiligne>"}`.
|
||||||
|
- **effet** : identique à l'`insert(/rag)` existant (stocke dans `rag_docs`, purge les
|
||||||
|
chunks de la source pour ré-ingestion, broadcast `RELOAD_RAG`).
|
||||||
|
- **retour** : `ok`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Export conversations (file-drop) — `conversations_export`
|
||||||
|
|
||||||
|
Ne JAMAIS renvoyer les conversations dans le `Bundle` (limite binder ~1 Mo). Le
|
||||||
|
provider **écrit un fichier** sur le stockage externe (pullable), Kazeia-central fait
|
||||||
|
`adb pull` puis (optionnel) demande la suppression.
|
||||||
|
|
||||||
|
- **arg** : filtre `{"profile_id": "?", "session_id": "?", "since": <ms?>, "until": <ms?>, "recipient": "<base64 X25519 pubkey?>", "plaintext": <bool?>}`.
|
||||||
|
Tous les filtres sont optionnels (absent = pas de borne). `profile_id` **et**
|
||||||
|
`session_id` peuvent être combinés ou omis (omis = tout).
|
||||||
|
- **effet** :
|
||||||
|
1. Lire les tours via le même chemin que `turnsForSessionCursor()` /
|
||||||
|
`sessionsCursor()` (la base est SQLCipher — seul le provider sait la déchiffrer).
|
||||||
|
2. Sérialiser en **NDJSON** (une ligne JSON par tour : `{id, profile_id, session_id,
|
||||||
|
timestamp, role, text, ttft_ms, total_ms, voice_used, model_used}`), précédé d'une
|
||||||
|
ligne d'en-tête `{"_meta": {schema, exported_at, count, filter}}`.
|
||||||
|
3. **Chiffrement (défaut)** : si `recipient` fourni (clé publique X25519 de
|
||||||
|
Kazeia-central), chiffrer le NDJSON en mode hybride
|
||||||
|
**libsodium** : clé symétrique aléatoire → `secretstream` XChaCha20-Poly1305 sur
|
||||||
|
le contenu ; la clé est scellée par `crypto_box_seal` vers `recipient`. Fichier de
|
||||||
|
sortie `*.ndjson.enc`. Le PC seul (clé privée) déchiffre — aucune PII en clair
|
||||||
|
ne touche le stockage partagé.
|
||||||
|
**Fallback debug** : `plaintext=true` → `*.ndjson` en clair (réservé dev).
|
||||||
|
4. Écrire dans `getExternalFilesDir("exports")` (=
|
||||||
|
`Android/data/com.kazeia/files/exports/`, pullable en `adb pull`). Nom :
|
||||||
|
`export_<timestamp>.ndjson[.enc]`.
|
||||||
|
- **retour** : `ok`, `path` = chemin absolu du fichier, `count` = nb de tours.
|
||||||
|
- **nettoyage** : prévoir une méthode `export_purge` (arg `{"path": "..."}` ou tout
|
||||||
|
purger) que le PC appelle après `adb pull` réussi. À défaut, purge des exports
|
||||||
|
> 24 h à l'`onCreate` du provider.
|
||||||
|
|
||||||
|
> Dépendance côté app : libsodium (`com.goterl:lazysodium-android` + `net.java.dev.jna`).
|
||||||
|
> Si l'ajout de dépendance est jugé trop lourd pour le MVP, livrer d'abord le mode
|
||||||
|
> `plaintext` (le PC pulle immédiatement puis `export_purge`), et planifier le
|
||||||
|
> chiffrement en incrément — **le signaler explicitement** (pas de PII en clair
|
||||||
|
> persistante silencieuse).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Schéma JSON de la config (source de vérité : `ConfigStore.kt`)
|
||||||
|
|
||||||
|
Forme **imbriquée** utilisée par `cfg_dump_json` (complète) et `cfg_apply_json`
|
||||||
|
(partielle). Noms de clés = colonnes du provider en `snake_case`.
|
||||||
|
|
||||||
|
### 5.1 Objet racine
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cascade_enabled": true,
|
||||||
|
"tts_enabled": true,
|
||||||
|
"stt_engine": "whisper",
|
||||||
|
"llm_engine": "engine",
|
||||||
|
"tts_engine": "cosyvoice",
|
||||||
|
"debug_enabled": false,
|
||||||
|
"rag": { "enabled": false, "threshold": 0.82, "top_k": 3 },
|
||||||
|
"speaker": { /* §5.2 */ },
|
||||||
|
"thinker": { /* §5.2 */ },
|
||||||
|
"presets": [ /* §5.3 */ ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 Bloc modèle (`speaker` / `thinker`)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_id": "qwen3.5-4b-gguf",
|
||||||
|
"system_prompt": "Tu es Kazeia...\n...",
|
||||||
|
"temperature": 0.7,
|
||||||
|
"top_p": 0.85,
|
||||||
|
"top_k": 40,
|
||||||
|
"repeat_penalty": 1.1,
|
||||||
|
"presence_penalty": 0.0,
|
||||||
|
"frequency_penalty": 0.0,
|
||||||
|
"max_tokens": 450,
|
||||||
|
"preset_name": "Équilibré (défaut)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Preset (élément du tableau `presets`)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Kaz chaleureux",
|
||||||
|
"temperature": 0.4, "top_p": 0.85, "top_k": 40,
|
||||||
|
"repeat_penalty": 1.05, "presence_penalty": 0.2,
|
||||||
|
"frequency_penalty": 0.2, "max_tokens": 256
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Aujourd'hui le moteur natif n'honore que `max_tokens` côté sampling
|
||||||
|
> (cf. `SAMPLING_ENGINE_SPEC.md`) ; Kazeia-central pousse tous les champs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Contrôle d'accès (à spécifier maintenant, activable plus tard)
|
||||||
|
|
||||||
|
Le provider est `exported=true` **sans permission** (cf. en-tête de
|
||||||
|
`KazeiaTelemetryProvider.kt`, ligne ~25). Toute app sideloadée peut lire les
|
||||||
|
conversations et changer la config.
|
||||||
|
|
||||||
|
- **Mauvaise piste** : `signature permission` — casserait l'uid `shell` (donc
|
||||||
|
Kazeia-central via adb) **et** l'app admin (sauf co-signature). À éviter.
|
||||||
|
- **Retenu** : **secret partagé**. Token aléatoire généré une fois (provisioning),
|
||||||
|
stocké en `EncryptedSharedPreferences` (`kazeia_central_token`), comparé par le
|
||||||
|
provider à `extras.getString("token")` sur les méthodes **mutantes** + l'export.
|
||||||
|
- Phase 1 : plomberie en place, **vérification désactivée** (token vide = tout
|
||||||
|
accepté) → ne bloque pas le démarrage.
|
||||||
|
- Phase 2 : provisioning du token (par la 1ʳᵉ connexion Kazeia-central) → activation.
|
||||||
|
- Comparaison en **temps constant** (`MessageDigest.isEqual`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Handshake / capacités — `capabilities`
|
||||||
|
|
||||||
|
Pour piloter une flotte de tablettes potentiellement sur des versions différentes.
|
||||||
|
|
||||||
|
- **arg** : aucun.
|
||||||
|
- **retour** : `result_b64` = base64 de :
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"app_version_code": 15,
|
||||||
|
"app_version_name": "0.2.3",
|
||||||
|
"provider_schema": 2,
|
||||||
|
"token_required": false,
|
||||||
|
"supported_calls": [
|
||||||
|
"update_check","update_install",
|
||||||
|
"cfg_dump_json","cfg_apply_json","presets_dump_json","presets_apply_json",
|
||||||
|
"profile_dump_json","profile_upsert_json","rag_upsert_json",
|
||||||
|
"conversations_export","export_purge","capabilities","rag_sync","voices_reload"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `provider_schema` : entier à incrémenter à chaque évolution de ce contrat. Permet à
|
||||||
|
Kazeia-central de dégrader proprement si une tablette est sur une vieille version.
|
||||||
|
- Optionnel : exposer aussi `app_version_code`/`provider_schema`/`token_required` en
|
||||||
|
colonnes supplémentaires de `/state` pour un polling léger sans base64.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Confort authoring local (nice-to-have)
|
||||||
|
|
||||||
|
- `rag_sync` (arg : aucun) → déclenche `Rag.syncDir(...)` après un `adb push` direct de
|
||||||
|
fichiers dans `rag_corpus/`. Broadcast `RELOAD_RAG`. Retour `ok`, `count` (docs ré-ingérés).
|
||||||
|
- `voices_reload` (arg : aucun) → diffuse le broadcast de reload des voix (remplacer la
|
||||||
|
string en dur ligne ~656 par une constante `ACTION_RELOAD_VOICES` au passage). Retour `ok`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Checklist de livraison (côté app patiente)
|
||||||
|
|
||||||
|
- [ ] Constantes des nouveaux noms `call()` dans le `companion object`.
|
||||||
|
- [ ] Helpers `ok/err/decode/encode` (§1.2).
|
||||||
|
- [ ] `cfg_dump_json`, `presets_dump_json`, `profile_dump_json` (§2).
|
||||||
|
- [ ] `cfg_apply_json`, `presets_apply_json`, `profile_upsert_json`, `rag_upsert_json` (§3) — **réutiliser** `updateConfig/mergeModel/upsertProfile/insert` existants.
|
||||||
|
- [ ] `conversations_export` (+ `export_purge`) (§4) — décider chiffré vs plaintext-MVP, le **documenter**.
|
||||||
|
- [ ] Gate token (§6) en plomberie désactivée.
|
||||||
|
- [ ] `capabilities` + bump `provider_schema` (§7).
|
||||||
|
- [ ] `rag_sync`, `voices_reload` + constante `ACTION_RELOAD_VOICES` (§8).
|
||||||
|
- [ ] Mettre à jour le commentaire d'en-tête du provider (lister les nouvelles méthodes).
|
||||||
|
|
||||||
|
Toutes les écritures via Kazeia-central passeront par ces méthodes ; tant qu'elles
|
||||||
|
n'existent pas, Kazeia-central reste en **lecture seule** sur les endpoints actuels.
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Kazeia-central — console de poste pilotant les tablettes Kazeia via USB/ADB."""
|
||||||
|
|
||||||
|
__version__ = "0.0.1"
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .client import Adb, AdbDevice, AdbError, ContentRow, parse_content_query
|
||||||
|
|
||||||
|
__all__ = ["Adb", "AdbDevice", "AdbError", "ContentRow", "parse_content_query"]
|
||||||
|
|
@ -0,0 +1,185 @@
|
||||||
|
"""Wrapper adb pour Kazeia-central.
|
||||||
|
|
||||||
|
Couche de transport bas niveau : découverte des tablettes, `content query/call`,
|
||||||
|
push/pull. Tout est ciblé par `serial` (multi-tablette).
|
||||||
|
|
||||||
|
PORTÉE (étapes 0-1) : lecture via `content query` sur les endpoints du provider
|
||||||
|
`com.kazeia.provider` dont les valeurs sont numériques ou des chaînes courtes.
|
||||||
|
Le texte riche (prompts, texte des tours, écriture de config) passera par les
|
||||||
|
méthodes `call()` base64-JSON décrites dans docs/PROVIDER_RPC_SPEC.md, ABSENTES
|
||||||
|
côté app patiente à ce jour → non implémentées ici tant qu'elles n'existent pas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
AUTHORITY = "com.kazeia.provider"
|
||||||
|
PROVIDER_URI = f"content://{AUTHORITY}"
|
||||||
|
|
||||||
|
ContentRow = dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class AdbError(RuntimeError):
|
||||||
|
"""Échec d'une commande adb (code de retour non nul ou adb introuvable)."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AdbDevice:
|
||||||
|
serial: str
|
||||||
|
state: str # "device", "unauthorized", "offline", ...
|
||||||
|
props: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model(self) -> str:
|
||||||
|
return self.props.get("model", "")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ready(self) -> bool:
|
||||||
|
return self.state == "device"
|
||||||
|
|
||||||
|
|
||||||
|
class Adb:
|
||||||
|
"""Façade sur le binaire `adb`. Instancier une fois, réutiliser."""
|
||||||
|
|
||||||
|
def __init__(self, adb_path: str = "adb", timeout: float = 30.0) -> None:
|
||||||
|
self.adb_path = adb_path
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
# ---- bas niveau --------------------------------------------------------
|
||||||
|
def _run(self, args: list[str], *, serial: str | None = None,
|
||||||
|
timeout: float | None = None, binary: bool = False) -> subprocess.CompletedProcess:
|
||||||
|
cmd = [self.adb_path]
|
||||||
|
if serial:
|
||||||
|
cmd += ["-s", serial]
|
||||||
|
cmd += args
|
||||||
|
try:
|
||||||
|
cp = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
timeout=timeout or self.timeout,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise AdbError(f"binaire adb introuvable: {self.adb_path}") from e
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
raise AdbError(f"timeout adb: {' '.join(args)}") from e
|
||||||
|
if cp.returncode != 0:
|
||||||
|
stderr = cp.stderr.decode("utf-8", "replace").strip()
|
||||||
|
raise AdbError(f"adb {' '.join(args)} -> rc={cp.returncode}: {stderr}")
|
||||||
|
return cp
|
||||||
|
|
||||||
|
def shell(self, command: str, *, serial: str | None = None,
|
||||||
|
timeout: float | None = None) -> str:
|
||||||
|
"""Exécute une commande shell. `command` est passé en un seul argument
|
||||||
|
(adb le ré-assemble) — éviter les métacaractères non maîtrisés."""
|
||||||
|
cp = self._run(["shell", command], serial=serial, timeout=timeout)
|
||||||
|
return cp.stdout.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
# ---- découverte --------------------------------------------------------
|
||||||
|
def devices(self) -> list[AdbDevice]:
|
||||||
|
out = self._run(["devices", "-l"]).stdout.decode("utf-8", "replace")
|
||||||
|
devices: list[AdbDevice] = []
|
||||||
|
for line in out.splitlines()[1:]: # saute l'en-tête "List of devices attached"
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split()
|
||||||
|
serial, state = parts[0], parts[1]
|
||||||
|
props = {}
|
||||||
|
for tok in parts[2:]:
|
||||||
|
if ":" in tok:
|
||||||
|
k, v = tok.split(":", 1)
|
||||||
|
props[k] = v
|
||||||
|
devices.append(AdbDevice(serial=serial, state=state, props=props))
|
||||||
|
return devices
|
||||||
|
|
||||||
|
def ready_devices(self) -> list[AdbDevice]:
|
||||||
|
return [d for d in self.devices() if d.ready]
|
||||||
|
|
||||||
|
# ---- provider : lecture (query) ---------------------------------------
|
||||||
|
def content_query(self, path: str, *, serial: str | None = None,
|
||||||
|
where: str | None = None) -> list[ContentRow]:
|
||||||
|
"""`content query` sur content://com.kazeia.provider/<path>.
|
||||||
|
|
||||||
|
⚠️ Le format de sortie d'`adb content query` (`Row: N k=v, k=v`) est
|
||||||
|
FRAGILE : il casse si une valeur contient `, ` ou un saut de ligne.
|
||||||
|
N'utiliser que pour les endpoints à valeurs simples. Pour le texte riche,
|
||||||
|
attendre les méthodes call() base64-JSON (PROVIDER_RPC_SPEC.md).
|
||||||
|
"""
|
||||||
|
cmd = f"content query --uri {PROVIDER_URI}/{path}"
|
||||||
|
if where is not None:
|
||||||
|
cmd += f' --where "{where}"'
|
||||||
|
return parse_content_query(self.shell(cmd, serial=serial))
|
||||||
|
|
||||||
|
# ---- provider : RPC (call) — pour les méthodes base64-JSON futures -----
|
||||||
|
def content_call(self, method: str, *, arg_json: Any | None = None,
|
||||||
|
extras: dict[str, str] | None = None,
|
||||||
|
serial: str | None = None) -> dict[str, Any]:
|
||||||
|
"""`content call` générique. Encode `arg_json` en base64(JSON) (convention
|
||||||
|
PROVIDER_RPC_SPEC §1). Nécessite les méthodes côté app patiente (à venir).
|
||||||
|
Retourne le Bundle parsé en dict."""
|
||||||
|
cmd = f"content call --uri {PROVIDER_URI} --method {method}"
|
||||||
|
if arg_json is not None:
|
||||||
|
payload = base64.b64encode(json.dumps(arg_json).encode("utf-8")).decode("ascii")
|
||||||
|
cmd += f" --arg {payload}"
|
||||||
|
for k, v in (extras or {}).items():
|
||||||
|
cmd += f" --extra {k}:s:{v}"
|
||||||
|
return parse_call_bundle(self.shell(cmd, serial=serial))
|
||||||
|
|
||||||
|
# ---- fichiers ----------------------------------------------------------
|
||||||
|
def pull(self, remote: str, local: str, *, serial: str | None = None) -> None:
|
||||||
|
self._run(["pull", remote, local], serial=serial, timeout=600)
|
||||||
|
|
||||||
|
def push(self, local: str, remote: str, *, serial: str | None = None) -> None:
|
||||||
|
self._run(["push", local, remote], serial=serial, timeout=600)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- parsing -------------------------------------------------------------
|
||||||
|
_ROW_RE = re.compile(r"^Row:\s*\d+\s+(.*)$")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_content_query(output: str) -> list[ContentRow]:
|
||||||
|
"""Parse la sortie texte d'`adb content query`.
|
||||||
|
|
||||||
|
Format : une ligne `Row: 0 col=val, col=val, ...` par ligne de résultat,
|
||||||
|
ou `No result found.`. Les valeurs `NULL` deviennent None. Limitation
|
||||||
|
documentée : un `, ` ou un saut de ligne dans une valeur casse le découpage.
|
||||||
|
"""
|
||||||
|
rows: list[ContentRow] = []
|
||||||
|
for line in output.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
m = _ROW_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
row: ContentRow = {}
|
||||||
|
for pair in m.group(1).split(", "):
|
||||||
|
if "=" not in pair:
|
||||||
|
continue
|
||||||
|
k, v = pair.split("=", 1)
|
||||||
|
row[k.strip()] = None if v == "NULL" else v
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def parse_call_bundle(output: str) -> dict[str, Any]:
|
||||||
|
"""Parse le `Result: Bundle[mParcelledData...]` d'`adb content call`.
|
||||||
|
|
||||||
|
L'outil rend une représentation peu structurée du Bundle ; on extrait les
|
||||||
|
paires `clé=valeur`. Suffisant pour les retours simples (ok, error, path,
|
||||||
|
count, result_b64). À durcir quand les méthodes call() seront livrées.
|
||||||
|
"""
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
m = re.search(r"Bundle\[\{(.*)\}\]", output, re.DOTALL)
|
||||||
|
if not m:
|
||||||
|
return result
|
||||||
|
for pair in m.group(1).split(", "):
|
||||||
|
if "=" in pair:
|
||||||
|
k, v = pair.split("=", 1)
|
||||||
|
result[k.strip()] = v.strip()
|
||||||
|
return result
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .app import create_app
|
||||||
|
|
||||||
|
__all__ = ["create_app"]
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""API FastAPI de Kazeia-central (étapes 0-1 : lecture de flotte).
|
||||||
|
|
||||||
|
Sert l'UI web locale et expose la lecture des endpoints provider existants par
|
||||||
|
tablette. Les écritures (config/presets/profils/RAG) et l'export conversations
|
||||||
|
seront ajoutés quand les méthodes call() base64-JSON seront livrées côté app
|
||||||
|
patiente (docs/PROVIDER_RPC_SPEC.md).
|
||||||
|
|
||||||
|
Lancer : `uvicorn kazeia_central.api.app:app --reload`
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
|
||||||
|
from ..adb import Adb, AdbError
|
||||||
|
from ..provider import ProviderClient
|
||||||
|
|
||||||
|
|
||||||
|
def create_app(adb: Adb | None = None) -> FastAPI:
|
||||||
|
adb = adb or Adb()
|
||||||
|
app = FastAPI(title="Kazeia-central", version="0.0.1")
|
||||||
|
|
||||||
|
def client(serial: str) -> ProviderClient:
|
||||||
|
return ProviderClient(adb, serial)
|
||||||
|
|
||||||
|
def _guard(fn):
|
||||||
|
try:
|
||||||
|
return fn()
|
||||||
|
except AdbError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=str(e)) from e
|
||||||
|
except IndexError as e: # endpoint a renvoyé 0 ligne
|
||||||
|
raise HTTPException(status_code=404, detail="aucune donnée") from e
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
def health():
|
||||||
|
return {"ok": True, "version": app.version}
|
||||||
|
|
||||||
|
@app.get("/api/devices")
|
||||||
|
def devices():
|
||||||
|
return [d.__dict__ for d in _guard(adb.devices)]
|
||||||
|
|
||||||
|
@app.get("/api/devices/{serial}/state")
|
||||||
|
def state(serial: str):
|
||||||
|
return _guard(lambda: client(serial).state()).model_dump()
|
||||||
|
|
||||||
|
@app.get("/api/devices/{serial}/turns")
|
||||||
|
def turns(serial: str):
|
||||||
|
return [t.model_dump() for t in _guard(lambda: client(serial).turns())]
|
||||||
|
|
||||||
|
@app.get("/api/devices/{serial}/crashes")
|
||||||
|
def crashes(serial: str):
|
||||||
|
return [c.model_dump() for c in _guard(lambda: client(serial).crashes())]
|
||||||
|
|
||||||
|
@app.get("/api/devices/{serial}/models")
|
||||||
|
def models(serial: str):
|
||||||
|
return [x.model_dump() for x in _guard(lambda: client(serial).models())]
|
||||||
|
|
||||||
|
@app.get("/api/devices/{serial}/voices")
|
||||||
|
def voices(serial: str):
|
||||||
|
return [v.model_dump() for v in _guard(lambda: client(serial).voices())]
|
||||||
|
|
||||||
|
@app.get("/api/devices/{serial}/rag/status")
|
||||||
|
def rag_status(serial: str):
|
||||||
|
return _guard(lambda: client(serial).rag_status()).model_dump()
|
||||||
|
|
||||||
|
@app.get("/api/devices/{serial}/rag/index")
|
||||||
|
def rag_index(serial: str):
|
||||||
|
return [d.model_dump() for d in _guard(lambda: client(serial).rag_index())]
|
||||||
|
|
||||||
|
@app.get("/api/devices/{serial}/updates")
|
||||||
|
def updates(serial: str):
|
||||||
|
return _guard(lambda: client(serial).updates()).model_dump()
|
||||||
|
|
||||||
|
@app.get("/api/devices/{serial}/profiles")
|
||||||
|
def profiles(serial: str):
|
||||||
|
return [p.model_dump() for p in _guard(lambda: client(serial).profiles())]
|
||||||
|
|
||||||
|
@app.get("/api/devices/{serial}/conversations")
|
||||||
|
def sessions(serial: str, profile_id: str | None = None):
|
||||||
|
return [s.model_dump() for s in _guard(lambda: client(serial).sessions(profile_id))]
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
from .client import ProviderClient
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
__all__ = ["ProviderClient", "models"]
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
"""Client typé du provider Kazeia, par tablette.
|
||||||
|
|
||||||
|
Enveloppe `Adb.content_query` et expose des accès typés (pydantic) aux endpoints
|
||||||
|
EXISTANTS du provider. Les écritures et la lecture du texte riche (config/prompts,
|
||||||
|
texte des conversations) dépendent des méthodes call() base64-JSON décrites dans
|
||||||
|
docs/PROVIDER_RPC_SPEC.md — non livrées côté app patiente → exposées ici comme
|
||||||
|
NotImplemented explicites pour cadrer l'intégration future.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..adb import Adb
|
||||||
|
from . import models as m
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderClient:
|
||||||
|
def __init__(self, adb: Adb, serial: str) -> None:
|
||||||
|
self.adb = adb
|
||||||
|
self.serial = serial
|
||||||
|
|
||||||
|
def _q(self, path: str, **kw) -> list[dict]:
|
||||||
|
return self.adb.content_query(path, serial=self.serial, **kw)
|
||||||
|
|
||||||
|
# ---- lecture (endpoints actuels, valeurs simples) ----------------------
|
||||||
|
def state(self) -> m.StateRow:
|
||||||
|
return m.StateRow(**self._q("state")[0])
|
||||||
|
|
||||||
|
def turns(self) -> list[m.TurnRow]:
|
||||||
|
return [m.TurnRow(**r) for r in self._q("turns")]
|
||||||
|
|
||||||
|
def crashes(self) -> list[m.CrashRow]:
|
||||||
|
return [m.CrashRow(**r) for r in self._q("crashes")]
|
||||||
|
|
||||||
|
def models(self) -> list[m.ModelRow]:
|
||||||
|
return [m.ModelRow(**r) for r in self._q("models")]
|
||||||
|
|
||||||
|
def voices(self) -> list[m.VoiceRow]:
|
||||||
|
return [m.VoiceRow(**r) for r in self._q("voices")]
|
||||||
|
|
||||||
|
def rag_status(self) -> m.RagStatus:
|
||||||
|
return m.RagStatus(**self._q("rag_status")[0])
|
||||||
|
|
||||||
|
def rag_index(self) -> list[m.RagDocRow]:
|
||||||
|
return [m.RagDocRow(**r) for r in self._q("rag")]
|
||||||
|
|
||||||
|
def rag_query(self, text: str) -> list[m.RagHit]:
|
||||||
|
# text peut contenir des espaces → fragile via query ; OK pour un test simple.
|
||||||
|
return [m.RagHit(**r) for r in self._q("rag_query", where=text)]
|
||||||
|
|
||||||
|
def updates(self) -> m.UpdateStatus:
|
||||||
|
return m.UpdateStatus(**self._q("updates")[0])
|
||||||
|
|
||||||
|
def dist_config(self) -> m.DistConfig:
|
||||||
|
return m.DistConfig(**self._q("dist_config")[0])
|
||||||
|
|
||||||
|
def sessions(self, profile_id: str | None = None) -> list[m.SessionRow]:
|
||||||
|
path = f"conversations/profile/{profile_id}" if profile_id else "conversations"
|
||||||
|
return [m.SessionRow(**r) for r in self._q(path)]
|
||||||
|
|
||||||
|
def profiles(self) -> list[m.ProfileRow]:
|
||||||
|
return [m.ProfileRow(**r) for r in self._q("profiles")]
|
||||||
|
|
||||||
|
def active_profile_id(self) -> str | None:
|
||||||
|
rows = self._q("profiles/active")
|
||||||
|
return rows[0].get("active_profile_id") if rows else None
|
||||||
|
|
||||||
|
# ---- MAJ OTA (call() déjà disponibles côté app) ------------------------
|
||||||
|
def update_check(self) -> dict:
|
||||||
|
return self.adb.content_call("update_check", serial=self.serial)
|
||||||
|
|
||||||
|
def update_install(self) -> dict:
|
||||||
|
return self.adb.content_call("update_install", serial=self.serial)
|
||||||
|
|
||||||
|
# ---- bloqué tant que PROVIDER_RPC_SPEC n'est pas livré côté app ---------
|
||||||
|
def dump_config(self):
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Nécessite la méthode call() `cfg_dump_json` (PROVIDER_RPC_SPEC §2.1) "
|
||||||
|
"— non livrée côté app patiente."
|
||||||
|
)
|
||||||
|
|
||||||
|
def session_turns(self, session_id: str):
|
||||||
|
raise NotImplementedError(
|
||||||
|
"Le texte des tours contient des sauts de ligne → `content query` casse. "
|
||||||
|
"Passer par `conversations_export` (PROVIDER_RPC_SPEC §4) une fois livré."
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
"""Modèles pydantic des endpoints du provider `com.kazeia.provider`.
|
||||||
|
|
||||||
|
Source de vérité : KazeiaTelemetryProvider.kt (kazeia-android) — vérifié contre le
|
||||||
|
code. Les valeurs d'`adb content query` arrivent en chaînes ; les validators
|
||||||
|
pydantic les coercent. Les booléens du provider sont des entiers 0/1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
def _as_bool(v):
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return v
|
||||||
|
return str(v).strip() in ("1", "true", "True")
|
||||||
|
|
||||||
|
|
||||||
|
class _Base(BaseModel):
|
||||||
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
|
class StateRow(_Base):
|
||||||
|
pid: int
|
||||||
|
uptime_seconds: int
|
||||||
|
pss_mb: int
|
||||||
|
ion_mb: int
|
||||||
|
# Colonnes présentes dans le code mais non documentées au §4.1 du CLAUDE.md
|
||||||
|
# (relevées par le dev) :
|
||||||
|
anon_pages_mb: int | None = None
|
||||||
|
mem_available_mb: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TurnRow(_Base):
|
||||||
|
timestamp: int
|
||||||
|
input_kind: str | None = None
|
||||||
|
status: str | None = None
|
||||||
|
total_ms: int | None = None
|
||||||
|
stt_ms: int | None = None
|
||||||
|
llm_ttft_ms: int | None = None
|
||||||
|
llm_gen_ms: int | None = None
|
||||||
|
llm_tokens: int | None = None
|
||||||
|
time_to_first_audio_ms: int | None = None
|
||||||
|
audio_playback_ms: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CrashRow(_Base):
|
||||||
|
timestamp: int
|
||||||
|
component: str | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ModelRow(_Base):
|
||||||
|
id: str
|
||||||
|
display_name: str | None = None
|
||||||
|
pte_path: str | None = None
|
||||||
|
tokenizer_path: str | None = None
|
||||||
|
role: str | None = None
|
||||||
|
size_mb: int | None = None
|
||||||
|
max_seq_len: int | None = None
|
||||||
|
file_exists: bool = False
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
_b = field_validator("file_exists", mode="before")(_as_bool)
|
||||||
|
|
||||||
|
|
||||||
|
class VoiceRow(_Base):
|
||||||
|
id: str
|
||||||
|
name: str | None = None
|
||||||
|
state: str | None = None
|
||||||
|
wav_exists: bool = False
|
||||||
|
prefix_exists: bool = False
|
||||||
|
suffix_exists: bool = False
|
||||||
|
wav_size_bytes: int | None = None
|
||||||
|
wav_duration_seconds: float | None = None
|
||||||
|
wav_path: str | None = None
|
||||||
|
created_at: int | None = None
|
||||||
|
|
||||||
|
_b = field_validator("wav_exists", "prefix_exists", "suffix_exists", mode="before")(_as_bool)
|
||||||
|
|
||||||
|
|
||||||
|
class RagStatus(_Base):
|
||||||
|
enabled: bool = False
|
||||||
|
ready: bool = False
|
||||||
|
model: str | None = None
|
||||||
|
dim: int | None = None
|
||||||
|
doc_count: int | None = None
|
||||||
|
chunk_count: int | None = None
|
||||||
|
pending: int | None = None
|
||||||
|
|
||||||
|
_b = field_validator("enabled", "ready", mode="before")(_as_bool)
|
||||||
|
|
||||||
|
|
||||||
|
class RagDocRow(_Base):
|
||||||
|
source: str
|
||||||
|
char_count: int | None = None
|
||||||
|
chunk_count: int | None = None
|
||||||
|
updated_at: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RagHit(_Base):
|
||||||
|
source: str
|
||||||
|
score: float
|
||||||
|
text: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateStatus(_Base):
|
||||||
|
phase: str
|
||||||
|
app_update_available: bool = False
|
||||||
|
app_version_name: str | None = None
|
||||||
|
app_size: int | None = None
|
||||||
|
content_count: int | None = None
|
||||||
|
content_size: int | None = None
|
||||||
|
progress_done: int | None = None
|
||||||
|
progress_total: int | None = None
|
||||||
|
label: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
last_check: int | None = None
|
||||||
|
|
||||||
|
_b = field_validator("app_update_available", mode="before")(_as_bool)
|
||||||
|
|
||||||
|
|
||||||
|
class DistConfig(_Base):
|
||||||
|
webdav_base: str | None = None
|
||||||
|
webdav_user: str | None = None
|
||||||
|
has_password: bool = False
|
||||||
|
is_customized: bool = False
|
||||||
|
|
||||||
|
_b = field_validator("has_password", "is_customized", mode="before")(_as_bool)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRow(_Base):
|
||||||
|
profile_id: str
|
||||||
|
session_id: str
|
||||||
|
started_at: int | None = None
|
||||||
|
ended_at: int | None = None
|
||||||
|
turn_count: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileRow(_Base):
|
||||||
|
id: str
|
||||||
|
display_name: str | None = None
|
||||||
|
avatar_color: int | None = None
|
||||||
|
voice_id: str | None = None
|
||||||
|
has_pin: bool = False
|
||||||
|
created_at: int | None = None
|
||||||
|
last_used_at: int | None = None
|
||||||
|
is_default: bool = False
|
||||||
|
turn_count: int | None = None
|
||||||
|
# system_prompt_override / notes : présents en colonne mais multilignes →
|
||||||
|
# à lire via `profile_dump_json` (RPC futur), pas via query.
|
||||||
|
|
||||||
|
_b = field_validator("has_pin", "is_default", mode="before")(_as_bool)
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
[project]
|
||||||
|
name = "kazeia-central"
|
||||||
|
version = "0.0.1"
|
||||||
|
description = "Console de poste (PC/Mac) qui pilote les tablettes Kazeia via USB/ADB"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.110",
|
||||||
|
"uvicorn[standard]>=0.29",
|
||||||
|
"pydantic>=2.6",
|
||||||
|
"httpx>=0.27", # WebDAV (OTA) — étapes ultérieures
|
||||||
|
"pynacl>=1.5", # libsodium : déchiffrement des exports conversations (§4 spec)
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
desktop = ["pywebview>=5.0"]
|
||||||
|
dev = ["pytest>=8.0", "ruff>=0.4"]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["kazeia_central"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
from kazeia_central.adb import parse_content_query
|
||||||
|
from kazeia_central.adb.client import parse_call_bundle
|
||||||
|
from kazeia_central.provider import models as m
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_rows():
|
||||||
|
out = (
|
||||||
|
"Row: 0 pid=1234, uptime_seconds=42, pss_mb=512, ion_mb=300, "
|
||||||
|
"anon_pages_mb=100, mem_available_mb=2048\n"
|
||||||
|
)
|
||||||
|
rows = parse_content_query(out)
|
||||||
|
assert len(rows) == 1
|
||||||
|
st = m.StateRow(**rows[0])
|
||||||
|
assert st.pid == 1234
|
||||||
|
assert st.mem_available_mb == 2048
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_null_and_empty():
|
||||||
|
assert parse_content_query("No result found.") == []
|
||||||
|
rows = parse_content_query("Row: 0 component=stt, message=NULL")
|
||||||
|
assert rows[0]["message"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_bool_coercion():
|
||||||
|
rows = parse_content_query("Row: 0 enabled=1, ready=0, model=e5-small, dim=384")
|
||||||
|
rs = m.RagStatus(**rows[0])
|
||||||
|
assert rs.enabled is True and rs.ready is False and rs.dim == 384
|
||||||
|
|
||||||
|
|
||||||
|
def test_call_bundle():
|
||||||
|
b = parse_call_bundle("Result: Bundle[{ok=true, count=3}]")
|
||||||
|
assert b["ok"] == "true" and b["count"] == "3"
|
||||||
Loading…
Reference in New Issue