ti-pote/docs/memory-system.md
ordinarthur 674109ea22 feat: initial project setup — Phase 0
Monorepo pnpm avec NestJS backend en architecture hexagonale.
- Structure hexagonale complète (ports, adapters, domain entities)
- 9 entities TypeORM (Home, User, Device, Credentials, Session, Message, Memory, Timer)
- Migration initiale SQL avec pgvector support
- Docker Compose (PostgreSQL 16 + pgvector + Redis 7)
- Config partagée (tsconfig, ESLint, Prettier)
- Outbound ports définis (STT, TTS, LLM, Cache, Storage, VectorStore)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 09:01:52 +01:00

11 KiB

Système de mémoire conversationnelle

Pourquoi une mémoire ?

Un LLM n'a aucune mémoire native. À chaque requête, il reçoit un bloc de texte (la context window) et répond uniquement sur la base de ce bloc. Si une information n'est pas dans la window, elle n'existe pas pour lui.

La "mémoire conversationnelle" de Ti-Pote, c'est un système que nous construisons pour sélectionner et injecter les bonnes informations dans le contexte du LLM à chaque échange. L'objectif : que Ti-Pote donne l'impression de se souvenir, de connaître l'utilisateur, et de maintenir une relation qui s'enrichit avec le temps.

Les 3 niveaux de mémoire

┌─────────────────────────────────────────────────────────────┐
│                    CONTEXT WINDOW DU LLM                     │
│                                                              │
│  ┌──────────────┐                                           │
│  │ System Prompt │  Personnalité, instructions, tools       │
│  └──────────────┘                                           │
│  ┌──────────────┐                                           │
│  │ Mémoire      │  Profil utilisateur + faits connus        │
│  │ Sémantique   │  (long terme, depuis PostgreSQL)          │
│  └──────────────┘                                           │
│  ┌──────────────┐                                           │
│  │ Mémoire      │  Résumés de conversations passées         │
│  │ Épisodique   │  pertinentes (moyen terme, via pgvector)  │
│  └──────────────┘                                           │
│  ┌──────────────┐                                           │
│  │ Mémoire de   │  Messages de la conversation en cours     │
│  │ Session      │  (court terme, depuis Redis)              │
│  └──────────────┘                                           │
│  ┌──────────────┐                                           │
│  │ Message      │  Ce que l'utilisateur vient de dire       │
│  │ Courant      │                                           │
│  └──────────────┘                                           │
└─────────────────────────────────────────────────────────────┘

Niveau 1 — Mémoire de session (court terme)

C'est le contexte de la conversation en cours. Quand l'utilisateur dit "mets un rendez-vous demain" puis "ajoute aussi Marie", le "aussi" fait référence au rendez-vous qu'on vient de mentionner.

Stockage : Redis (clé session:{id}, liste ordonnée de messages).

Durée de vie : La session expire après un timeout configurable (par défaut 3 minutes de silence). L'utilisateur peut aussi terminer explicitement ("Merci Ti-Pote", "C'est bon").

Contenu : Chaque message inclut le rôle (user/assistant/tool), le contenu textuel, et les éventuels tool calls et résultats.

Gestion de la taille : Si l'historique de session dépasse le budget de tokens alloué, les messages les plus anciens sont résumés en un bloc compact par le LLM, et seuls les N derniers messages sont gardés verbatim.

Niveau 2 — Mémoire épisodique (moyen terme)

C'est le souvenir des conversations passées. "Ce matin tu m'as dit que mon colis arrivait aujourd'hui" ou "La semaine dernière on avait parlé de ce restaurant."

Stockage : PostgreSQL (tables ConversationSession et MemoryEntry).

Cycle de vie :

  1. La session de conversation se termine (timeout ou fin explicite).
  2. Un worker asynchrone prend l'historique complet de la session.
  3. Le worker envoie l'historique au LLM avec un prompt d'extraction :
Analyse cette conversation et extrais :
1. Un résumé en 2-3 phrases
2. Les faits importants mentionnés (décisions, événements, informations personnelles)
3. Les actions réalisées (rendez-vous créés, emails envoyés, etc.)
4. Les préférences ou habitudes révélées par l'utilisateur

Retourne le résultat en JSON structuré.
  1. Le worker stocke le résumé dans ConversationSession.summary et les faits dans MemoryEntry.
  2. Le worker génère des embeddings pour chaque fait/résumé et les stocke dans MemoryEmbedding.

Récupération : Quand une nouvelle conversation commence, le ContextBuilder fait une recherche sémantique (similarity search) dans pgvector avec le message de l'utilisateur comme requête. Les souvenirs les plus pertinents sont injectés dans le contexte.

Niveau 3 — Mémoire sémantique (long terme)

C'est la connaissance accumulée sur l'utilisateur au fil du temps. "L'utilisateur préfère le café au thé", "Il travaille chez Acme Corp", "Sa femme s'appelle Sophie".

Stockage : PostgreSQL (MemoryEntry avec type = 'profile' ou 'preference').

Construction : Le même worker qui traite la mémoire épisodique met à jour le profil utilisateur. Si le LLM extrait un nouveau fait de type "préférence" ou "info personnelle", il est ajouté ou mis à jour dans le profil.

Structure du profil : Le profil est un ensemble de MemoryEntry de type 'profile', chacun représentant un fait sur l'utilisateur. Exemples :

  • "L'utilisateur s'appelle Arthur" (type: profile)
  • "Il préfère la pizza quatre fromages" (type: preference)
  • "Son rendez-vous dentiste est le 15 de chaque mois" (type: fact)
  • "Il travaille sur le projet Ti-Pote" (type: profile)

Déduplication et mise à jour : Si un nouveau fait contredit un ancien ("En fait je préfère la margherita maintenant"), le worker doit identifier l'ancien fait et le mettre à jour plutôt que d'en créer un nouveau. On utilise la similarité sémantique pour détecter les doublons.

Le ContextBuilder en détail

Le ContextBuilder est le service central qui assemble le prompt pour chaque requête LLM.

Flux d'exécution

Message utilisateur ("Rappelle-moi ce qu'on avait dit sur mon déménagement")
    │
    ▼
┌──────────────────────────────────────────────┐
│ ContextBuilder                                │
│                                               │
│ 1. Charger le system prompt                   │
│ 2. Charger le profil utilisateur (PG)         │
│ 3. Recherche sémantique avec le message       │
│    courant comme requête (pgvector)            │
│    → top K souvenirs pertinents               │
│ 4. Charger l'historique de session (Redis)    │
│ 5. Charger les définitions de tools           │
│ 6. Assembler le tout en respectant            │
│    le budget de tokens                        │
└──────────────────────────────────────────────┘
    │
    ▼
Prompt complet envoyé au LLM

Budget de tokens

La context window a une taille limitée (128K tokens pour GPT-4 Turbo, 200K pour Claude). On ne veut pas la remplir entièrement (coût + bruit). Budget recommandé :

Bloc Budget (% de la window) Priorité
System prompt + tools ~15% Fixe, toujours inclus
Message courant ~5% Fixe, toujours inclus
Historique de session ~30% Haute — tronqué si nécessaire
Profil utilisateur ~10% Haute — résumé si trop long
Souvenirs pertinents ~20% Moyenne — top K par pertinence
Marge pour la réponse ~20% Réservée

Si le budget est dépassé, le ContextBuilder applique les règles de priorité : il réduit d'abord les souvenirs (prend moins de résultats), puis résume l'historique de session, puis tronque le profil.

Recherche sémantique

La recherche sémantique transforme le message de l'utilisateur en vecteur et cherche les vecteurs les plus proches dans la base d'embeddings.

// Pseudo-code de la recherche sémantique
async function findRelevantMemories(
  userId: string,
  query: string,
  topK: number = 5,
): Promise<MemoryEntry[]> {
  // 1. Générer l'embedding du message courant
  const queryEmbedding = await embeddingService.embed(query);

  // 2. Recherche par similarité cosinus dans pgvector
  const results = await db.query(`
    SELECT me.*, 1 - (emb.embedding <=> $1) AS similarity
    FROM memory_entries me
    JOIN memory_embeddings emb ON emb.memory_id = me.id
    WHERE me.user_id = $2 AND me.is_active = true
    ORDER BY emb.embedding <=> $1
    LIMIT $3
  `, [queryEmbedding, userId, topK]);

  // 3. Filtrer par seuil de similarité minimum
  return results.filter(r => r.similarity > 0.7);
}

L'opérateur <=> de pgvector calcule la distance cosinus. Plus la distance est faible (similarité haute), plus le souvenir est pertinent par rapport à la requête.

Droit à l'oubli

L'utilisateur doit pouvoir contrôler sa mémoire :

  • Via la voix : "Ti-Pote, oublie ce que je t'ai dit sur X" → le core marque les MemoryEntry concernés comme is_active = false.
  • Via l'app : interface de consultation et suppression des souvenirs. L'utilisateur voit une liste de ce que Ti-Pote "sait" et peut supprimer individuellement ou faire un reset complet.
  • Reset complet : supprime toutes les MemoryEntry et MemoryEmbedding de l'utilisateur. L'historique des sessions est conservé (pour audit) mais les résumés et faits extraits sont effacés.

Le LLM a aussi un tool dédié :

{
  name: 'forget_memory',
  description: 'Oublie un souvenir spécifique à la demande de l\'utilisateur',
  parameters: {
    type: 'object',
    properties: {
      query: {
        type: 'string',
        description: 'Description de ce que l\'utilisateur veut oublier'
      },
      scope: {
        type: 'string',
        enum: ['specific', 'topic', 'all'],
        description: 'Oublier un fait précis, tout un sujet, ou tout'
      }
    },
    required: ['query', 'scope']
  }
}

Coût et optimisation

La mémoire a un coût en tokens LLM (pour l'extraction à chaque fin de session) et en API d'embeddings. Optimisations prévues :

  • Batch les embeddings : ne pas générer un embedding par fait, mais regrouper les faits d'une session en un seul appel.
  • Cache les embeddings de requête : si la même requête revient souvent, ne pas la ré-embedder.
  • Pruning périodique : un job mensuel qui supprime les souvenirs anciens avec un score d'importance faible et jamais rappelés.
  • Modèle d'embedding léger : utiliser un modèle d'embedding moins cher que le modèle de conversation (ex: text-embedding-3-small d'OpenAI ou un modèle open source local).