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

207 lines
11 KiB
Markdown

# 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é.
```
4. Le worker stocke le résumé dans `ConversationSession.summary` et les faits dans `MemoryEntry`.
5. 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.
```typescript
// 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é :
```typescript
{
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).