HubMix

Documentation

Kotlin et Android

Le SDK Maven de Gaspard pour Android (minSdk 21) et la JVM : coroutines, Flow pour le flux, Ktor et Spring.

SDK officiel du hub HubMix pour l'API publique v1 (/v1/public) : Gaspard, l'assistant IA, et depuis la 1.1.0 le tuteur, la confiance (signaux d'arnaque des annonces), les embeddings et la vérification des webhooks (portail développeurs retiré le 28 octobre 2026 : plus aucun webhook n'est envoyé). Il sert aussi bien dans une application Android (clé publiable, session visiteur, consentement) que sur un serveur JVM (Ktor, Spring… avec une clé secrète).

  • Coroutines : toutes les méthodes sont suspend et main-safe (le réseau tourne sur Dispatchers.IO) ; annuler la coroutine annule l'appel HTTP.
  • Réponses en flux (askStream) : un Flow froid d'événements Delta puis Completed.
  • OkHttp 4.12, kotlinx.serialization, kotlinx.coroutines. Bytecode JVM 17, compatible Android (minSdk 21 et plus).

Installation #

Kotlin
// settings.gradle.kts
dependencyResolutionManagement {
    repositories {
        mavenCentral()
        mavenLocal() // tant que le SDK n'est publié que localement (./gradlew publishToMavenLocal)
    }
}

// build.gradle.kts du module
dependencies {
    implementation("ch.valais-connect:gaspard:1.1.0")
}

Le SDK vise Kotlin 2.0 (métadonnées, API et stdlib 2.0.21 ; kotlinx-coroutines 1.9 et kotlinx-serialization 1.7) : il se lit avec n'importe quel compilateur Kotlin 2.x, dont le Kotlin intégré à l'Android Gradle Plugin 9. Vérifié avec une app Android neuve (AGP 9.1.1, minSdk 24), compilée en release avec R8 et lancée sur un émulateur Android 12.

Sur Android, les coroutines du thread principal (Dispatchers.Main, lifecycleScope, viewModelScope) viennent de kotlinx-coroutines-android, déjà présent avec androidx.lifecycle ; sans lui, ajoutez implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0").

Configuration #

Kotlin
val gaspard = GaspardClient {
    apiKey = "vck_live_…"            // OU publishableKey = "vcp_live_…" (exactement une des deux)
    baseUrl = "https://hubmix.dealmood-solutions.com" // par défaut ; le SDK ajoute /v1/public
    maxRetries = 2                   // par défaut ; 0 désactive les nouvelles tentatives
    timeout = 60.seconds             // par requête ; un flux n'a pas de délai total
    // okHttpClient = monOkHttp      // pour partager connexions, intercepteurs, proxy…
}
Option Rôle
apiKey Clé serveur vck_live_… → Authorization: Bearer. Jamais dans une application mobile.
publishableKey Clé widget / application vcp_live_… → X-Gaspard-Key.
origin Avec une clé publiable hors navigateur (Android) : envoyée comme Origin, doit faire partie des origines déclarées pour le client.
endUser Clés serveur : l'identifiant de votre utilisateur → X-End-User (seul un hachage est stocké).
userAgent gaspard-kotlin/1.1.0 par défaut.

Une erreur de configuration (aucune clé ou les deux, consent = false, endUser avec une clé publiable, identifiant de requête mal formé…) lève une IllegalArgumentException avant toute requête. Le client est thread-safe : créez-en un seul et partagez-le.

Chaque méthode accepte en dernier paramètre options = RequestOptions(requestId = "…", endUser = "…") (identifiant de requête X-Request-Id, utilisateur final pour cet appel seulement).

Méthodes #

Méthode Appel
privacyNotice(locale = "fr") GET /privacy-notice — le texte à faire accepter
openSession(consent = true, locale, allowTraining) POST /sessions — clé publiable ; garde le jeton visiteur
visitorToken / setVisitorToken(token) lire / restaurer (ou oublier avec null) le jeton vst_…
startConversation(locale = "fr", page = null) POST /gaspard/conversations
ask(conversationId, text, firstName = null) réponse complète (Answer)
askStream(conversationId, text, firstName = null) Flow<GaspardStreamEvent>
conversation(conversationId) la conversation et ses messages
feedback(messageId, Rating.UP / Rating.DOWN, comment = null) note une réponse
searchKnowledge(query, limit = 5, topics = null) recherche dans les connaissances
consent(), giveConsent(noticeVersion, allowTraining), withdrawConsent() consentement
exportData(), eraseData() droit d'accès, droit à l'effacement
forEndUser(id) clé serveur : un client dérivé pour l'un de vos utilisateurs
products() GET /products — les produits du hub
tutor.explain(lesson, messages, mode, learner, locale) clé serveur : POST /tutor/explanations
tutor.quiz(lesson, count, difficulty, kinds, learner, locale) clé serveur : POST /tutor/quizzes
tutor.flashcards(lesson, count, learner, locale) clé serveur : POST /tutor/flashcards
tutor.grade(lesson, question, answer, countryCode, locale) clé serveur : POST /tutor/grades
trust.assess(listings) clé serveur : POST /trust/assessments
embeddings.create(texts) clé serveur : POST /embeddings
GaspardWebhooks.verify(payload, signature, secrets) vérifie un webhook du portail

Les types (Answer, Conversation, Listing…) sont des data class en camelCase. Les champs inconnus renvoyés par le serveur sont ignorés et les valeurs d'énumération inconnues deviennent UNKNOWN : une évolution de l'API ne casse pas votre application.

Dates #

Les dates restent des chaînes ISO 8601 ("2026-10-13T10:00:00Z"), car java.time n'existe qu'à partir d'Android 8 (API 26). Pour un Instant :

Kotlin
val debut: Instant = conversation.startedAt.toGaspardInstant()

Sous l'API 26, activez le desugaring (isCoreLibraryDesugaringEnabled = true et la dépendance com.android.tools:desugar_jdk_libs) avant d'utiliser toGaspardInstant().

Android #

Ce qu'il faut savoir #

  • Permission : <uses-permission android:name="android.permission.INTERNET" /> dans AndroidManifest.xml.
  • Clé publiable seulement : une application utilise une clé vcp_live_… + origin (l'une des origines déclarées pour ce client, p. ex. https://app.mon-domaine.ch). Ne mettez jamais une clé secrète vck_live_… dans un APK : elle peut en être extraite en quelques minutes.
  • Consentement : avant openSession, affichez le texte de privacyNotice() et ne poursuivez que si la personne l'accepte. Le SDK refuse openSession(consent = false).
  • Jeton visiteur : il vit 30 jours ; enregistrez-le (DataStore) et restaurez-le au démarrage avec setVisitorToken, sinon chaque lancement crée un nouveau visiteur.
  • R8 / ProGuard : les règles nécessaires à kotlinx.serialization pour les modèles du SDK sont livrées dans le jar (META-INF/proguard/gaspard.pro) et appliquées automatiquement. OkHttp et kotlinx.serialization livrent les leurs.
  • Thread principal : vous pouvez appeler le SDK depuis viewModelScope (Dispatchers.Main) : il bascule lui-même sur Dispatchers.IO.

Exemple : ViewModel, consentement, flux, DataStore #

Kotlin
// Un seul client pour toute l'application (p. ex. dans votre conteneur d'injection).
val gaspard = GaspardClient {
    publishableKey = BuildConfig.GASPARD_PUBLISHABLE_KEY // vcp_live_…
    origin = "https://app.mon-domaine.ch"
}

private val Context.gaspardStore by preferencesDataStore(name = "gaspard")
private val VISITOR_TOKEN = stringPreferencesKey("visitor_token")

data class ChatUiState(
    val notice: PrivacyNotice? = null,      // non null : afficher le dialogue de consentement
    val answer: String = "",
    val sources: List<Source> = emptyList(),
    val loading: Boolean = false,
    val error: String? = null,
)

class ChatViewModel(
    private val gaspard: GaspardClient,
    private val store: DataStore<Preferences>,
) : ViewModel() {
    private val _state = MutableStateFlow(ChatUiState())
    val state: StateFlow<ChatUiState> = _state.asStateFlow()
    private var conversationId: String? = null
    private var asking: Job? = null

    init {
        viewModelScope.launch {
            val saved = store.data.first()[VISITOR_TOKEN]
            if (saved != null) {
                gaspard.setVisitorToken(saved)
            } else {
                // Pas encore de session : montrer la notice avant tout.
                _state.update { it.copy(notice = gaspard.privacyNotice(locale = "fr")) }
            }
        }
    }

    /** Appelé par le bouton « J'accepte » du dialogue. */
    fun acceptNotice(allowTraining: Boolean) = viewModelScope.launch {
        try {
            val session = gaspard.openSession(consent = true, locale = "fr", allowTraining = allowTraining)
            store.edit { it[VISITOR_TOKEN] = session.token }
            _state.update { it.copy(notice = null) }
        } catch (e: GaspardException) {
            _state.update { it.copy(error = e.message) }
        }
    }

    fun ask(question: String) {
        asking?.cancel() // une nouvelle question annule la précédente (et son appel HTTP)
        asking = viewModelScope.launch {
            _state.update { it.copy(answer = "", sources = emptyList(), loading = true, error = null) }
            try {
                val id = conversationId ?: gaspard.startConversation(locale = "fr", page = "accueil").id
                    .also { conversationId = it }
                gaspard.askStream(id, question).collect { event ->
                    when (event) {
                        is GaspardStreamEvent.Delta ->
                            _state.update { it.copy(answer = it.answer + event.text) }
                        is GaspardStreamEvent.Completed ->
                            _state.update { it.copy(answer = event.answer.text, sources = event.answer.sources) }
                    }
                }
            } catch (e: GaspardException) {
                when (e.code) {
                    "session_required" -> { // jeton expiré ou effacé : redemander le consentement
                        gaspard.setVisitorToken(null)
                        store.edit { it.remove(VISITOR_TOKEN) }
                        _state.update { it.copy(notice = gaspard.privacyNotice()) }
                    }
                    "conversation_full" -> conversationId = null // la prochaine question en ouvre une autre
                    else -> _state.update { it.copy(error = "Gaspard est indisponible, réessayez.") }
                }
            } finally {
                _state.update { it.copy(loading = false) }
            }
        }
    }
}

Dans l'interface (Compose), affichez notice.text dans un AlertDialog avec « J'accepte » et « Refuser » (refuser = ne pas ouvrir de session), puis state.answer qui se remplit au fil du flux. Après l'effacement des données (eraseData()), le client oublie de lui-même le jeton visiteur ; supprimez aussi celui enregistré dans DataStore.

Serveur (clé secrète) #

Ktor #

Kotlin
val gaspard = GaspardClient {
    apiKey = System.getenv("GASPARD_API_KEY") // vck_live_…, jamais côté client
}

fun Application.gaspardRoutes() = routing {
    post("/assistant/{conversationId}") {
        val user = call.principal<UserIdPrincipal>()!!.name
        val question = call.receiveText()
        // Une conversation par utilisateur de votre service : X-End-User
        val answer = gaspard.forEndUser(user).ask(call.parameters["conversationId"]!!, question)
        call.respond(mapOf("text" to answer.text, "sources" to answer.sources.map { it.name }))
    }
}

Spring (WebFlux / coroutines) #

Kotlin
@Configuration
class GaspardConfiguration {
    @Bean
    fun gaspard(@Value("\${gaspard.api-key}") apiKey: String): GaspardClient =
        GaspardClient { this.apiKey = apiKey }
}

@RestController
class AssistantController(private val gaspard: GaspardClient) {
    @PostMapping("/assistant/conversations")
    suspend fun start(principal: Principal): Conversation =
        gaspard.forEndUser(principal.name).startConversation(locale = "fr")

    @GetMapping("/assistant/knowledge")
    suspend fun search(@RequestParam q: String): List<Passage> =
        gaspard.searchKnowledge(q, limit = 5).passages
}

Avec une clé serveur, consent(), giveConsent(), exportData() et eraseData() concernent l'utilisateur final indiqué par endUser / forEndUser : montrez-lui d'abord privacyNotice().

Produits du hub #

Le tuteur, la confiance et les embeddings sont activés par application par l'équipe HubMix (sur demande) ; sans le produit, l'API répond 403 insufficient_scope. Ils demandent une clé secrète : avec une clé publiable (application Android), l'appel lève une IllegalArgumentException avant toute requête. Les valeurs que l'API peut étendre (niveaux, verdicts, signaux) sont des chaînes.

Kotlin
val result = gaspard.trust.assess(
    listOf(TrustListing(kind = "housing", title = annonce.titre, description = annonce.texte,
        priceAmount = annonce.loyer, currency = "EUR", countryCode = "FR", city = "Lyon")),
)
if (result.assessments.first().withheld) { /* à faire vérifier ; findings dit pourquoi */ }

val cartes = gaspard.tutor.flashcards(Lesson(title = "La photosynthèse", content = texte), count = 8)
val vecteurs = gaspard.embeddings.create(listOf("chambre à louer", "room for rent")).embeddings

Webhooks #

L'ancien portail développeurs (retiré le 28 octobre 2026) signait chaque webhook (X-VC-Signature: t=…,v1=…). Vérifiez-le sur le corps brut :

Kotlin
post("/webhooks/valais-connect") {   // Ktor
    val body = call.receive<ByteArray>()
    try {
        val event = GaspardWebhooks.verify(body, call.request.headers["X-VC-Signature"], listOf(secret))
        // dédoublonner sur event.id, puis traiter event.type (key.revoked, quota.warning…)
        call.respond(HttpStatusCode.NoContent)
    } catch (e: WebhookVerificationException) {
        call.respond(HttpStatusCode.BadRequest, e.code)
    }
}

Tolérance de 300 s par défaut ; pendant une rotation, passez les deux secrets. Comparaison en temps constant (MessageDigest.isEqual).

Flux (Server-Sent Events) #

Kotlin
gaspard.askStream(conversationId, "Wo finde ich einen Studentenjob in Berlin?").collect { event ->
    when (event) {
        is GaspardStreamEvent.Delta -> print(event.text)
        is GaspardStreamEvent.Completed -> println("\nSources : ${event.answer.sources}")
    }
}

Le Flow est froid : chaque collect pose la question. Un événement error du serveur lève une GaspardException (assistant_busy et assistant_unavailable → 503, conversation_full → 409) ; un flux qui se termine sans réponse lève stream_incomplete. Annuler la collecte (first(), cancel(), fin du viewModelScope) ferme la connexion.

Erreurs et nouvelles tentatives #

Toute erreur est une GaspardException :

Propriété Contenu
status statut HTTP, 0 sans réponse
code error.code de l'API (consent_required, rate_limited…), sinon http_<statut> ; network_error, timeout, invalid_response, stream_incomplete côté client
message message pour les développeurs
requestId identifiant à communiquer au support
retryAfterSeconds délai demandé par Retry-After, s'il y en a un
Kotlin
try {
    gaspard.ask(conversationId, question)
} catch (e: GaspardException) {
    when (e.code) {
        "rate_limited", "assistant_busy" -> afficher("Réessayez dans ${e.retryAfterSeconds ?: 5} s")
        "token_quota_exceeded" -> afficher("Quota du mois atteint")
        else -> log.warn("Gaspard ${e.status} ${e.code} (requête ${e.requestId})", e)
    }
}

Le SDK réessaie lui-même, jusqu'à maxRetries fois (2 par défaut) : 503 assistant_busy, 429 rate_limited et, pour GET et DELETE seulement, les erreurs réseau et les délais dépassés. Il attend Retry-After secondes (au-delà de 30 s, il ne réessaie pas et lève l'erreur), sinon 0,5 s puis 1 s, 2 s… Jamais pour token_quota_exceeded, les autres erreurs 4xx, ni un flux déjà commencé. Reposer une question est sans risque : le serveur n'enregistre un échange qu'avec sa réponse.