15 KiB
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
ContentProviderKazeiaTelemetryProviderun jeu de méthodescall()qui transportent du JSON encodé base64, pour permettre à Kazeia-central (poste PC, viaadb 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 viacontent 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:valcasse dès quevalcontient:ou un espace (gotcha confirmé). Orspeaker_system_promptest multiligne et contient:. - Lecture —
content queryrendRow: 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'--argdansarget chaque--extradansextras. C'est la même signature que lesupdate_check/update_installdé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é :
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 queconfigCursor()(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, dontsystem_prompt_overridemultiligne).ok=false, error=not_foundsi 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 colonnetext= la fiche RAG complète (titres,:, listes, sauts de ligne). La relire viaquery /rag/{source}la corrompt exactement comme un prompt. Symétrique derag_upsert_json(§3.4) — sans cette méthode, l'authoring RAG serait écriture-seule.ok=false, error=not_foundsi 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 enquery: 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 enquery.
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) etmergeModel()(~453+). Réutiliser ces helpers ; ne pas dupliquer la logique. DiffuserACTION_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(...), broadcastRELOAD_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).idabsent → création. - effet : réutiliser
upsertProfile(ContentValues)en construisant leContentValuesdepuis le JSON. BroadcastRELOAD_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 :pinabsent = PIN inchangé ;pin=""= efface le PIN ;pin="1234"= définit (le provider hashe en SHA-256). Le client central ne connaît jamaispin_hash(cf.profile_dump_jsonqui rendhas_pin). - retour :
ok, etresult_b64= base64{"id": "<id résultant>"}.
3.4 rag_upsert_json
- arg :
{"source": "<id>", "text": "<fiche multiligne>"}. - effet : identique à l'
insert(/rag)existant (stocke dansrag_docs, purge les chunks de la source pour ré-ingestion, broadcastRELOAD_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_idetsession_idpeuvent être combinés ou omis (omis = tout). - effet :
- Lire les tours via le même chemin que
turnsForSessionCursor()/sessionsCursor()(la base est SQLCipher — seul le provider sait la déchiffrer). - 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}}. - Chiffrement (défaut) : si
recipientfourni (clé publique X25519 de Kazeia-central), chiffrer le NDJSON en mode hybride libsodium : clé symétrique aléatoire →secretstreamXChaCha20-Poly1305 sur le contenu ; la clé est scellée parcrypto_box_sealversrecipient. 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→*.ndjsonen clair (réservé dev). - Écrire dans
getExternalFilesDir("exports")(=Android/data/com.kazeia/files/exports/, pullable enadb pull). Nom :export_<timestamp>.ndjson[.enc].
- Lire les tours via le même chemin que
- 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èsadb pullréussi. À défaut, purge des exports24 h à l'
onCreatedu 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 modeplaintext(le PC pulle immédiatement puisexport_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 queConfigStore.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 quetoJson()/fromJson()les lisent et écrivent, donccfg_dump_json/cfg_apply_jsonréutilisent ces helpers sans couche de mapping. Seulsspeaker/thinker/presetssont imbriqués (idemtoJson).
{
"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)
{
"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)
{
"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_tokenscô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'uidshell(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 :
{
"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_requireden colonnes supplémentaires de/statepour un polling léger sans base64.
8. Confort authoring local (nice-to-have)
rag_sync(arg : aucun) → déclencheRag.syncDir(...)après unadb pushdirect de fichiers dansrag_corpus/. BroadcastRELOAD_RAG. Retourok,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 constanteACTION_RELOAD_VOICESau passage). Retourok.
9. Checklist de livraison (côté app patiente)
- Constantes des nouveaux noms
call()dans lecompanion 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éutiliserupdateConfig/mergeModel/upsertProfile/insertexistants.conversations_export(+export_purge) (§4) — décider chiffré vs plaintext-MVP, le documenter.- Gate token (§6) en plomberie désactivée.
capabilities+ bumpprovider_schema(§7).rag_sync,voices_reload+ constanteACTION_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.