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:
Kazeia Team 2026-06-18 14:51:41 +02:00
commit 311236cecc
14 changed files with 1252 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@ -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

322
CLAUDE.md Normal file
View File

@ -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).

34
README.md Normal file
View File

@ -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).

299
docs/PROVIDER_RPC_SPEC.md Normal file
View File

@ -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.

View File

@ -0,0 +1,3 @@
"""Kazeia-central — console de poste pilotant les tablettes Kazeia via USB/ADB."""
__version__ = "0.0.1"

View File

@ -0,0 +1,3 @@
from .client import Adb, AdbDevice, AdbError, ContentRow, parse_content_query
__all__ = ["Adb", "AdbDevice", "AdbError", "ContentRow", "parse_content_query"]

View File

@ -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 -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

View File

@ -0,0 +1,3 @@
from .app import create_app
__all__ = ["create_app"]

85
kazeia_central/api/app.py Normal file
View File

@ -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()

View File

@ -0,0 +1,4 @@
from .client import ProviderClient
from . import models
__all__ = ["ProviderClient", "models"]

View File

@ -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é."
)

View File

@ -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)

27
pyproject.toml Normal file
View File

@ -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"

32
tests/test_adb_parse.py Normal file
View File

@ -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"