# 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. ### 2.4 `rag_dump_json` - **arg** : `{"source": ""}`. - **retour** : `result_b64` = base64 de `{"source": "...", "text": ""}`. - **pourquoi** : `ragDocCursor` (lignes ~209-213) renvoie la colonne `text` = la **fiche RAG complète** (titres, `:`, listes, sauts de ligne). La relire via `query /rag/{source}` la corrompt exactement comme un prompt. Symétrique de `rag_upsert_json` (§3.4) — sans cette méthode, l'authoring RAG serait écriture-seule. `ok=false, error=not_found` si la source n'existe pas. > Les autres lectures (`/state`, `/turns`, `/crashes`, `/models`, `/voices`, > `/rag_status`, `/updates`, `/dist_config`, l'**index** RAG `/rag`, liste des sessions > `/conversations`) **restent en `query`** : leurs valeurs sont numériques ou des chaînes > courtes sans `:`/saut de ligne. Seul le **texte** d'une fiche (`/rag/{source}`) exige > le dump §2.4 ; son index (`/rag` : `source, char_count, chunk_count, updated_at`) reste > en `query`. --- ## 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`. - **PIN** (sémantique déjà gérée par `upsertProfile`, l. 493-508) — passer le PIN **en clair** dans la clé `pin`, jamais le hash : `pin` **absent** = PIN inchangé ; `pin=""` = efface le PIN ; `pin="1234"` = définit (le provider hashe en SHA-256). Le client central ne connaît jamais `pin_hash` (cf. `profile_dump_json` qui rend `has_pin`). - **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 > ⚠️ Forme = **miroir exact de `runtime.json`** (ce que `ConfigStore.toJson()` > sérialise, vérifié l. 204-231). Les clés RAG sont **à plat** > (`rag_enabled`/`rag_threshold`/`rag_top_k`), **PAS** imbriquées sous `"rag"` : > c'est ainsi que `toJson()`/`fromJson()` les lisent et écrivent, donc > `cfg_dump_json`/`cfg_apply_json` réutilisent ces helpers **sans couche de > mapping**. Seuls `speaker`/`thinker`/`presets` sont imbriqués (idem `toJson`). ```json { "cascade_enabled": false, "tts_enabled": true, "stt_engine": "prod", "llm_engine": "prod", "tts_engine": "cosyvoice", "rag_enabled": false, "rag_threshold": 0.82, "rag_top_k": 3, "debug_enabled": false, "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","rag_dump_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`, `rag_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.