351 lines
16 KiB
Markdown
351 lines
16 KiB
Markdown
# 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`.
|
|
|
|
---
|
|
|
|
## ✅ LIVRÉ — app patiente `com.kazeia` vc19 / 0.2.7 (2026-07-01)
|
|
|
|
Toutes les méthodes de la checklist §9 sont implémentées et **validées sur device**
|
|
(round-trip texte riche avec `:` + sauts de ligne intact via base64, export file-drop
|
|
pullable + purge). Implémentation : commit `a420d00` (`KazeiaTelemetryProvider.call()`
|
|
+ `ConfigStore.exportJson()/applyPatchJson()`).
|
|
|
|
**Écarts assumés à connaître côté Kazeia-central :**
|
|
1. **`conversations_export` = PLAINTEXT MVP.** Le chiffrement hybride libsodium (§4.3)
|
|
est **différé** (pas de dépendance JNA/lazysodium ajoutée pour ce jalon). Le fichier
|
|
`.ndjson` est écrit **en clair** dans `Android/data/com.kazeia/files/exports/`.
|
|
→ **Kazeia-central DOIT `adb pull` puis appeler `export_purge` immédiatement.**
|
|
Garde-fous en place : purge auto des exports > 24 h à l'`onCreate`, et
|
|
`export_purge` refuse tout chemin hors du dossier `exports/`. Le param `recipient`
|
|
est accepté mais **ignoré** tant que le chiffrement n'est pas livré (à planifier si
|
|
la PII doit transiter par un stockage non-fiable).
|
|
2. **`rag_sync` renvoie `error=not_found` si le pipeline n'est pas chargé**
|
|
(`RagHolder.instance` nul — ex. tablette fraîche, `KazeiaService` jamais démarré).
|
|
À appeler après que l'app patiente a tourné au moins une fois.
|
|
3. **Gate token (§6) = phase 1 (désactivé).** `token_required=false` dans
|
|
`capabilities`. La plomberie `checkToken()` est en place (comparaison temps constant
|
|
sur les méthodes mutantes + export) ; il reste à provisionner le secret côté
|
|
Kazeia-central puis à activer (lecture `EncryptedSharedPreferences`).
|
|
4. `provider_schema = 2`.
|
|
|
|
---
|
|
|
|
## 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.
|
|
|
|
### 2.4 `rag_dump_json`
|
|
- **arg** : `{"source": "<id>"}`.
|
|
- **retour** : `result_b64` = base64 de `{"source": "...", "text": "<fiche multiligne>"}`.
|
|
- **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": "<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
|
|
> ⚠️ 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.
|