Kazeia-central/docs/PROVIDER_RPC_SPEC.md

16 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 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 :

  • Écriturecontent update --bind k:s:val casse dès que val contient : ou un espace (gotcha confirmé). Or speaker_system_prompt est multiligne et contient :.
  • Lecturecontent 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é :

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

{
  "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_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 :
{
  "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.