From 311236cecc836bae39d62cdd9909d5ce158465b8 Mon Sep 17 00:00:00 2001 From: Kazeia Team Date: Thu, 18 Jun 2026 14:51:41 +0200 Subject: [PATCH] =?UTF-8?q?chore:=20socle=20Kazeia-central=20(=C3=A9tapes?= =?UTF-8?q?=200-1=20lecture)=20+=20spec=20provider=20RPC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- .gitignore | 17 ++ CLAUDE.md | 322 ++++++++++++++++++++++++++++ README.md | 34 +++ docs/PROVIDER_RPC_SPEC.md | 299 ++++++++++++++++++++++++++ kazeia_central/__init__.py | 3 + kazeia_central/adb/__init__.py | 3 + kazeia_central/adb/client.py | 185 ++++++++++++++++ kazeia_central/api/__init__.py | 3 + kazeia_central/api/app.py | 85 ++++++++ kazeia_central/provider/__init__.py | 4 + kazeia_central/provider/client.py | 85 ++++++++ kazeia_central/provider/models.py | 153 +++++++++++++ pyproject.toml | 27 +++ tests/test_adb_parse.py | 32 +++ 14 files changed, 1252 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 docs/PROVIDER_RPC_SPEC.md create mode 100644 kazeia_central/__init__.py create mode 100644 kazeia_central/adb/__init__.py create mode 100644 kazeia_central/adb/client.py create mode 100644 kazeia_central/api/__init__.py create mode 100644 kazeia_central/api/app.py create mode 100644 kazeia_central/provider/__init__.py create mode 100644 kazeia_central/provider/client.py create mode 100644 kazeia_central/provider/models.py create mode 100644 pyproject.toml create mode 100644 tests/test_adb_parse.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..13ece9f --- /dev/null +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ee3ab72 --- /dev/null +++ b/CLAUDE.md @@ -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 +# 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 `. 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) `. + +## 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). diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c863da --- /dev/null +++ b/README.md @@ -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). diff --git a/docs/PROVIDER_RPC_SPEC.md b/docs/PROVIDER_RPC_SPEC.md new file mode 100644 index 0000000..2324f6f --- /dev/null +++ b/docs/PROVIDER_RPC_SPEC.md @@ -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 \ + --arg \ + [--extra token:s:] +``` +- `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": ""}`. +- **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": ""}`. + +### 3.4 `rag_upsert_json` +- **arg** : `{"source": "", "text": ""}`. +- **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": , "until": , "recipient": "", "plaintext": }`. + 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_.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. diff --git a/kazeia_central/__init__.py b/kazeia_central/__init__.py new file mode 100644 index 0000000..2bbc579 --- /dev/null +++ b/kazeia_central/__init__.py @@ -0,0 +1,3 @@ +"""Kazeia-central — console de poste pilotant les tablettes Kazeia via USB/ADB.""" + +__version__ = "0.0.1" diff --git a/kazeia_central/adb/__init__.py b/kazeia_central/adb/__init__.py new file mode 100644 index 0000000..a733bb2 --- /dev/null +++ b/kazeia_central/adb/__init__.py @@ -0,0 +1,3 @@ +from .client import Adb, AdbDevice, AdbError, ContentRow, parse_content_query + +__all__ = ["Adb", "AdbDevice", "AdbError", "ContentRow", "parse_content_query"] diff --git a/kazeia_central/adb/client.py b/kazeia_central/adb/client.py new file mode 100644 index 0000000..3084513 --- /dev/null +++ b/kazeia_central/adb/client.py @@ -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/. + + ⚠️ 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 diff --git a/kazeia_central/api/__init__.py b/kazeia_central/api/__init__.py new file mode 100644 index 0000000..b94a1e8 --- /dev/null +++ b/kazeia_central/api/__init__.py @@ -0,0 +1,3 @@ +from .app import create_app + +__all__ = ["create_app"] diff --git a/kazeia_central/api/app.py b/kazeia_central/api/app.py new file mode 100644 index 0000000..92900e7 --- /dev/null +++ b/kazeia_central/api/app.py @@ -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() diff --git a/kazeia_central/provider/__init__.py b/kazeia_central/provider/__init__.py new file mode 100644 index 0000000..78b18e5 --- /dev/null +++ b/kazeia_central/provider/__init__.py @@ -0,0 +1,4 @@ +from .client import ProviderClient +from . import models + +__all__ = ["ProviderClient", "models"] diff --git a/kazeia_central/provider/client.py b/kazeia_central/provider/client.py new file mode 100644 index 0000000..bf59323 --- /dev/null +++ b/kazeia_central/provider/client.py @@ -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é." + ) diff --git a/kazeia_central/provider/models.py b/kazeia_central/provider/models.py new file mode 100644 index 0000000..30e5cbf --- /dev/null +++ b/kazeia_central/provider/models.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..321cb28 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/tests/test_adb_parse.py b/tests/test_adb_parse.py new file mode 100644 index 0000000..4f102ec --- /dev/null +++ b/tests/test_adb_parse.py @@ -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"