KOHAMI Docs
Guides

Mémoire et cache

KOHAMI distingue trois mécanismes souvent confondus : la mémoire long-terme (faits durables sur le senior, rappelés sémantiquement d'une session à l'autre), le CacheFlux (réponses pré-écrites pour les tours triviaux, zéro appel LLM), et le résumé de conversation (synthèse légère d'un échange, destinée au tableau de bord aidant). Ils vivent tous dans le contexte borné Conversation (apps/api/src/modules/conversation/), derrière des ports : le domaine ne touche jamais le SDK Mistral ni pgVector directement.

Cette page explique comment chacun marche (flux, fichiers, requêtes SQL) et pourquoi les décisions ont été prises ainsi : le gate consentement systématique (INV-C01), le dual-write Zep/pgVector (R-03), le palier Small pour les sous-agents (ADR model policy), et le refus de persister le verbatim (RGPD).

Mémoire long-terme

La mémoire est exposée par un port unique, MemoryPort (contracts/memory.port.ts), avec trois opérations : persist(fact), search(query, seniorId, limit) et deleteSenior(seniorId). Un MemoryFact porte { id, seniorId, content, category, embedding }category est l'une des six catégories canoniques (profil, contacts, sante, localisation, vision, biometrie), strictement alignées sur les catégories de consentement RGPD.

Recall sémantique pgVector (cosine)

PgVectorMemoryAdapter (infrastructure/adapters/pgvector-memory.adapter.ts) est l'implémentation de référence. Les faits sont stockés dans la table memory_facts avec un vecteur d'embedding 1024-dim (Mistral Embed, mistral-embed). Le recall projette la requête à travers le MemoryEmbedderPort puis classe les faits par distance cosine contre l'index HNSW :

SELECT mf.id, mf.senior_id, mf.fact, mf.category, mf.embedding
  FROM memory_facts mf
  JOIN consents c
    ON c.senior_id = mf.senior_id
   AND c.category = mf.category
   AND c.revoked_at IS NULL
 WHERE mf.senior_id = $1::uuid
 ORDER BY mf.embedding <=> $2::vector
 LIMIT $3

L'opérateur <=> est la distance cosine pgVector : plus la distance est faible, plus le fait est sémantiquement proche de la requête. Le senior demande « est-ce que ma fille passe ce week-end ? » et le fait stocké « sa fille Claire habite à Lyon » remonte en tête, même sans recouvrement lexical.

Le JOIN consents n'est pas cosmétique : c'est l'application server-side de INV-C01. Un fait dont la catégorie de consentement a été révoquée (revoked_at IS NOT NULL) disparaît du recall instantanément, sans purge ni job de fond. Les deux chemins de recherche (cosine et chronologique) partagent la même projection CONSENT_FILTERED_PROJECTION : impossible de contourner le gate en changeant la stratégie de tri.

Fallback chronologique : pas de tri sur un vecteur dégénéré

Un vecteur de requête vide ou entièrement à zéro rend la distance cosine indéfinie (NaN) : un ORDER BY cosine sur une telle direction produirait un classement aléatoire silencieux. PgVectorMemoryAdapter détecte ce cas (isDegenerateVector) et bascule explicitement sur un tri chronologique (ORDER BY mf.created_at DESC), avec un log warn pour que la régression soit observable plutôt que masquée.

Ce cas se produit en dev-local sans MISTRAL_API_KEY : le NoopMemoryEmbedder émet précisément un vecteur nul. La recherche dégrade alors vers « les N faits les plus récents, toujours consent-filtrés » : la mémoire reste fonctionnelle, simplement non sémantique. Le même fallback couvre l'échec transport de l'embedder (catch autour de embedder.embed).

L'embedder lui-même (MistralEmbedClient, mistral-embed-client.adapter.ts) rejette toute réponse dont le nombre de dimensions diverge de 1024 (MistralEmbedClientDimsMismatchError) : une défense contre un swap silencieux de modèle vectoriel en amont, qui rendrait l'index HNSW incohérent. Le baseURL est validé contre la whitelist EU (api.mistral.ai, eu.api.mistral.ai) au constructeur (INV-T02).

Dual-write Zep / pgVector (R-03)

L'addendum v2.4 R-03 (validé JUWA le 2026-04-20) prévoit Zep (Graphiti) en principal, pgVector en backup synchrone. C'est DualWriteMemoryAdapter (dual-write-memory.adapter.ts) qui formalise ce couplage. Le MemoryProvider (infrastructure/providers/memory.provider.ts) choisit l'implémentation à l'amorçage selon la configuration :

  • ZEP_API_URL et ZEP_API_KEY renseignés → DualWriteMemoryAdapter enveloppant ZepMemoryAdapter (principal) + PgVectorMemoryAdapter (backup).
  • Sinon → PgVectorMemoryAdapter seul. C'est le défaut courant : l'instance Zep Scaleway n'est pas encore provisionnée (DIV-002), donc en production aujourd'hui la mémoire tourne sur pgVector seul. Le ZepMemoryAdapter lève ZepNotProvisionedError sur persist/search tant que l'URL n'est pas posée.

Le comportement du dual-write quand les deux backends sont câblés :

OpérationStratégieÉchec
persistLes deux legs en parallèle (Promise.allSettled), Zep lent ne bloque pas pgVectorLes deux legs en échec → MemoryUnavailableError (l'extraction Inngest amont retry). Un seul leg OK → succès + log
searchZep d'abord ; sur erreur ou résultat vide, fallback pgVectorLes deux legs en échec → liste vide (best-effort, le senior continue de parler)
deleteSeniorLes deux legs en parallèle ; le compte retourné est la somme des legs réussisAu moins un leg doit réussir (INV-P04 RGPD 72h), sinon MemoryUnavailableError

Chaque issue par backend est tracée sur kohami.memory.dual_write_total{backend,operation,outcome} (Cockpit), ce qui permet d'alerter sur un taux de divergence Zep↔pgVector. Note importante : le deleteSenior de ZepMemoryAdapter non provisionné retourne 0 (no-op) plutôt que de lever : l'effacement RGPD doit continuer à cascader sur les autres stores même si Zep est offline ; seuls persist/search lèvent l'erreur explicite.

Extraction de faits (Mistral Small, post-session)

Les faits ne sont pas extraits en temps réel sur le chemin voix. Ils sont dérivés après la session, dans un worker Inngest, par MistralSmallMemoryExtractor (mistral-small-memory-extractor.adapter.ts). L'adapter transforme le transcript de session en une liste typée de faits via generateObject (AI SDK v6 + @ai-sdk/mistral), sur Mistral Small (mistral-small-2603).

Le choix du palier Small est tranché par l'ADR apps/api/docs/decisions/llm-model-policy.md (révision 2026-06-09) : tous les agents LLM de KOHAMI tournent en Small (mistral-small-2603), Helena comprise ; Large et Medium sont interdits (directive budget, latence). Une extraction batch asynchrone s'accommode d'autant mieux de ce palier qu'elle n'a aucune contrainte de raisonnement long.

Plusieurs gardes au niveau de la frontière :

  • Schéma Zod gating l'enum de catégorie. Le schéma strict par fait (ExtractedFactSchema) valide category contre les six catégories canoniques ; une catégorie hors-liste hallucinée par le modèle échoue au parse au lieu d'atteindre la persistance. Le schéma externe est .passthrough()-tolérant : une entrée malformée est rejetée individuellement (log warn) sans faire tomber tout le batch.
  • Seuil de confiance. Les faits sous 0.6 de confiance sont droppés.
  • Garde anti-copier-coller. Un fait dont le contenu (≥ 50 caractères) apparaît littéralement dans le transcript est rejeté : le modèle a recopié une phrase au lieu de synthétiser un fait durable.
  • Retry transport 1× (defense-in-depth). L'extraction étant async, un seul retry n'ajoute que ~600 ms dans le pire cas ; sans lui, un Mistral 503 transitoire perdrait silencieusement tous les faits de la session, et le prochain filet (retry de la fonction Inngest, 30 s) est trop grossier pour une fermeture SSE intermittente. Les timeouts et schema-mismatches sont exclus du retry par design.
  • Circuit-breaker 5000 ms par tentative, plus large que les classifiers du hot path car le prompt porte ~20 tours de transcript.

Les faits validés sont ensuite persistés via MemoryPort.persist (qui embed chaque fait puis écrit dans memory_facts, et en dual-write aussi dans Zep). La persistance reste soumise à INV-C01 : un fait n'est rappelable que si la catégorie de consentement est active au moment du search.

Recall dans l'orchestrateur (Flux 2)

Le recall est branché sur le Flux 2 de l'orchestrateur via MemoryFluxAdapter (memory-flux.adapter.ts). Le flux gate d'abord sur le consentement conversation (ConsentPort.hasConsent) : si absent, il renvoie une réponse neutre déterministe (« Je suis là pour discuter avec vous. ») pour que le kiosque ne reste jamais muet, sans toucher la mémoire. Sinon il appelle memoryPort.search(transcript, seniorId, 5) et compose une réponse à partir des faits remontés. L'enveloppe LLM complète (Mistral + faits injectés dans le contexte) est portée par l'agent (Flux 3) ; voir Orchestrateur et modèles.

CacheFlux : réponses pré-écrites

CacheFluxAdapter (cache-flux.adapter.ts) est le Flux 1 : un cache déterministe in-memory, sans appel réseau, pour les tours triviaux. Il résout un intent depuis le transcript via un petit jeu de regex, puis fait tourner un curseur sur les variations pré-écrites de cet intent. La résolution est sub-milliseconde : le budget latence INV-C07 est trivialement tenu sur ce flux.

Comment il marche

const INTENT_PATTERNS = [
  [/^(bonjour|salut|coucou|bonsoir)\b/i, "greeting"],
];
const MAX_TRANSCRIPT_LEN_FOR_CACHE = 30;
  1. Si le transcript fait plus de 30 caractères → resolveIntent renvoie null (un tour long n'est jamais une formule triviale).
  2. Sinon, la première regex qui matche donne l'intent. Aujourd'hui l'unique intent câblé en production est greeting.
  3. Pour greeting, l'adapter pioche la prochaine variation via un curseur par intent (cursors: Map), ce qui évite que le kiosque réentende la même ligne deux fois de suite, et personnalise le préfixe avec le preferredName quand il est connu (« Bonjour Marie, ravi de vous revoir. »).
  4. Si aucune regex ne matche, l'adapter renvoie null : l'orchestrateur interprète ce null comme un cache MISS et bascule vers le Flux 3 (agent Mistral Medium). C'est volontaire : la pire régression serait de servir une réponse canée pour chaque tour hors-pattern.

Les intents « météo / heure / routine matinale » apparaissent dans l'ADR cache-regex-vs-mistral-classifier.md comme piste, mais ne sont pas câblés dans le CacheFluxAdapter shippé : seuls greeting et sos_fallback existent dans DEFAULT_VARIATIONS. La météo et l'heure passent par les tools de l'agent (Flux 3), pas par le cache. Ne pas documenter ces intents comme actifs.

Deux gardes métier

  • Fresh senior. À la toute première session (aucun slot d'onboarding rempli), l'intent greeting est sauté (context.isFreshSenior === truenull) : le prompt onboarding-aware doit composer un véritable premier accueil (« pas de relation antérieure », doctrine SOP V3.2 §5) plutôt que de rejouer « ravi de vous revoir » alors qu'il n'y a jamais eu de « avant ».
  • SOS fallback. L'entrée sos_fallback (« Bien sûr, je vous écoute. ») n'est jamais renvoyée pour un tour normal. Elle est réservée au chemin dégradé de l'orchestrateur (timeouts, crash LLM, tous flux en échec) via l'accesseur dédié sosFallback() : un dernier recours pour ne pas laisser le senior face au silence.

Invalidation

Le CacheFluxAdapter n'est pas un read-cache au sens des bugs Nathan-class : ses entrées sont des templates statiques (variations pré-écrites au boot via DEFAULT_VARIATIONS ou cache-variations.seed.ts), jamais alimentés par un store amont qu'un writer externe pourrait muter. La seule structure mutable est le cursors: Map de rotation des variations, qui est du per-session-state pur (aucun risque out-of-band).

C'est exactement la distinction posée par l'ADR apps/api/docs/decisions/cache-invalidation-contract-testing.md : un cache in-memory n'exige un contract test d'invalidation (3 AC : DELETE / UPDATE / INSERT) que lorsqu'il reflète un store amont susceptible d'être muté hors-bande (admin SQL direct, fork Inngest parallèle, déploiement multi-réplica). Les read-caches concernés en production sont par exemple OnboardingService.persistedSnapshots ou EntitlementService.cache (TTL), pas le CacheFlux. La règle est enforcée en CI par tests/architecture/no-cache-without-invalidation.test.ts, qui exige pour toute Map/Set/Record privée soit un test invalidation, soit une entrée allowlist justifiée, soit une exemption inline.

Résumé de conversation

Le résumé de conversation est une synthèse légère (2-3 phrases, français, troisième personne) d'un échange, destinée au tableau de bord aidant. Il est produit par MistralSmallConversationSummarizer (mistral-small-conversation-summarizer.adapter.ts) sur Mistral Small (mistral-small-2603) : une courte synthèse n'a jamais besoin de Medium, jamais de Large (ADR model policy).

Au grain du TOUR, pendant le mood-scoring

Le résumé est dérivé par tour, dans la même fonction Inngest que le score d'humeur : MoodScoringCaptureFunction (application/inngest/mood-scoring-capture.function.ts), déclenchée par l'event mood/score.requested. Le déroulé d'un tour :

  1. Gate consentement conversation (ConsentPort.hasConsent). Si absent, la fonction skip entièrement : pas de score, pas de résumé. Dériver un sentiment ou une synthèse, même vers un agrégat privé, requiert un consentement actif (INV-C01).
  2. Le mood-classifier produit un score 0-100 mappé sur un tier (preoccupant/melancolique/neutre/enjoue).
  3. Après le gate, deriveSummary appelle le summarizer (si câblé) sur { transcript, replyText } du tour.
  4. La row mood_scores est insérée avec source: "per_turn" et le champ summary.

Le résumé est best-effort : un échec du summarizer (timeout, transport, schema, sortie vide, ou exception) résout vers null, et la row de score est quand même persistée. La synthèse est un enrichissement, jamais un bloqueur du chemin mood. Le summarizer câble les mêmes défenses que l'extracteur de faits : retry transport 1×, circuit-breaker 4000 ms, et une garde anti-copier-coller (echoesTranscriptVerbatim) qui drop toute sortie réincluant un long verbatim du transcript en entrée.

Pourquoi pas au grain de la SESSION

Le résumé est volontairement au grain du tour, jamais au grain de la session. La raison est RGPD : le verbatim de la conversation n'est pas persisté. Côté worker voix, la synthèse TTS est éphémère (stream uniquement, aucun stockage automatique, c'est l'objet de l'AC-06-V-19, garde INV-C01). Sans verbatim stocké, il n'existe aucune source pour reconstruire un résumé global de session a posteriori. Le résumé par tour, dérivé à la volée pendant que le transcript est encore en mémoire et immédiatement réduit à 2-3 phrases neutres, est donc le seul grain disponible, et c'est aussi le grain le moins intrusif (pas de profilage longitudinal du contenu, juste un repère par échange pour l'aidant).

Le prompt système du summarizer impose la troisième personne (« Le senior a évoqué… »), le ton factuel et neutre (pas de diagnostic, pas d'interprétation médicale), et l'interdiction de recopier le transcript mot pour mot.

Exposition : GET mood-entries

Le résumé est surfacé sur GET /v1/admin/seniors/:id/mood-entries (admin / aidant scopé), une vue brute du journal d'humeur : une row par capture mood_scores, sans agrégation (à l'opposé de /mood-timeline qui retourne le rollup quotidien). Le contrôleur est AdminSeniorsViewsController.listMoodEntries (administration/controllers/admin-seniors-views.controller.ts), servi par PgAdminMoodEntriesRepository (administration/repositories/pg-admin-mood-entries.repository.ts).

Chaque entrée porte data[].summary :

ChampValeur
score0-100
confidence0-1
sourceper_turn | end_of_day | manual_entry | onboarding_j7
summaryrésumé 2-3 phrases sur les captures per_turn ; null sur les autres sources, sur les rows legacy, ou si le consentement conversation était absent au tour
notetoujours null (la table ne porte pas de note ; champ exposé pour symétrie avec le journal côté senior)
captured_atalias de recorded_at

La requête est paginée par keyset (recorded_at DESC, id DESC) (cursor opaque base64url, limit 1..200 défaut 50), filtrable par ?source=, ?from=, ?to=. La clause senior_id = $1::uuid est obligatoire (INV-P05 RLS, un senior ne voit que ses données), le contrôle de rôle admin vivant dans la couche service. Cohérence avec AC-06-A-62 et AC-06-V-19 : l'adapter ne persiste jamais le transcript verbatim.

Aller plus loin