# MyInfoMate — AI Persona & RAG Architecture Analysis ## Contexte MyInfoMate est une plateforme SaaS white-label pour lieux culturels (musées, forts, offices du tourisme). Les smart glasses Ray-Ban Meta sont **un canal parmi d'autres** (app mobile, borne, web) — pas le produit central. Le produit central reste MyInfoMate + son CMS. Les lunettes consomment les mêmes données et endpoints. --- ## Vision : AI Persona par venue Chaque client (= "venue") peut configurer un **guide IA personnalisé** via une phrase simple dans le CMS : > "Mon guide s'appelle Léon, il parle en belge familier, il est spécialisé sur le Moyen-Âge" - Cette phrase devient le **system prompt** injecté dans Gemini Flash - Configurable par venue, voire par zone/salle dans le venue - Activable sur tous les canaux : app mobile, smart glasses, borne --- ## Architecture RAG ### Stack retenue (décidé 2026-08-07) | Composant | Choix | Raison | |---|---|---| | Vector store | **pgvector** (extension PostgreSQL) | Zéro infra ajoutée, EF Core natif, transaction atomique contenu ↔ vecteurs | | Embeddings | API Google (`text-embedding-*`) | Même clé API que Gemini, pas de modèle local → **pas de GPU** | | Ingestion | Hangfire background job | Déjà en place, le save CMS ne bloque pas | | Génération | Gemini Flash via `IChatClient` | Déjà branché dans `AssistantService` | > **Qdrant a été écarté.** Évalué pour MyAssistant, il n'apporte rien à ces volumes : on parle de ~6 000 vecteurs par venue, ~60 000 pour 10 venues (~200 Mo). pgvector tient sans effort jusqu'à ~1M de vecteurs. Qdrant devient pertinent vers 10M+ ou avec du filtrage multi-attributs complexe — hors trajectoire. Le retenir coûterait un conteneur de plus, ~1 Go de RAM, un volume à sauvegarder à part, et la perte du rollback transactionnel avec le contenu. pgvector est **une extension Postgres**, au même titre que PostGIS déjà utilisé : un `CREATE EXTENSION vector;` ajoute un type de colonne `vector(768)`, des opérateurs de distance (`<=>` cosine) et des index HNSW. Pas de process, pas de port, pas de backup séparé — les vecteurs sont des lignes dans `my_info_mate`, embarquées par `pg_dump`. ### Endpoint `/ask` 1. Vectorise la question utilisateur 2. Recherche les top-5 chunks dans `ContentEmbeddings` filtrés sur le venue 3. Injecte le contexte + persona (system prompt) dans Gemini Flash 4. Retourne la réponse + sources ### Source des données Deux alimentations, toutes deux via Hangfire au save : - **Contenu CMS** : fiches, descriptions, POIs, sections - **Documents uploadés** : PDF déposés par le client (voir section suivante) --- ## Ingestion de documents PDF (décidé 2026-08-07) Le client peut uploader ses PDF (brochures, dossiers de presse, guides) et le guide IA s'en sert pour répondre. Le fichier **reste dans Firebase Storage** — rien ne change au stockage. Ce qui s'ajoute, c'est un job qui va le *lire*. ### Flux ``` manager-app upload le PDF → Firebase Storage ↓ POST /api/Resource (Url Firebase, Type = PDF) ← ResourceController.Create ↓ Hangfire.Enqueue (queue "ingestion") Job d'ingestion : 1. Télécharge le PDF depuis resource.Url → fichier temporaire 2. Extraction du texte page par page (PdfPig) 3. Si page vide → fallback OCR Gemini (voir cascade) 4. Découpage en chunks (~500-800 tokens, avec chevauchement) 5. Embedding de chaque chunk → float[768] 6. INSERT dans ContentEmbeddings (ContentType = "resource") ↓ Le PDF est interrogeable par le guide IA ``` À la question du visiteur, `/ask` ne distingue pas l'origine : les chunks remontés peuvent venir d'un POI ou d'un PDF. **Le PDF n'est jamais envoyé à Gemini au moment de la question** — seulement les 2-3 paragraphes pertinents. ### Cascade d'extraction — ne payer que ce qui le mérite ``` 1. PdfPig sur tout le document → coût 0 2. Mesure : caractères extraits / page 3. Si > ~100 car/page en moyenne → terminé (~70 % des cas) 4. Sinon, sur les pages vides uniquement → OCR Gemini 5. Si toujours rien → statut Failed + message au client ``` Coût zéro sur les PDF numériques (exports InDesign, Word), fallback payant seulement sur les scans. ### OCR : Gemini plutôt que Tesseract | | Tesseract | **Gemini** (retenu) | |---|---|---| | Dépendances Docker | libtesseract + libleptonica + PDFium (rasterisation) + un `.traineddata` par langue (~20 Mo × 10) | aucune | | Rasterisation des pages | obligatoire (Tesseract ne lit pas un PDF) | inutile, l'API avale le PDF nativement | | Temps (100 pages) | 2-5 min mono-thread, sur notre CPU | quelques secondes, sur leur infra | | RAM | ~25 Mo par page bitmap 300 DPI | négligeable | | Multilingue | détection de langue + pack par langue | natif, nos 10 langues d'office | | Qualité sur brochure (colonnes, typo fantaisie, texte sur photo) | médiocre | bonne, sortie markdown structurée | | Prix | 0 (Apache 2.0) mais coût de mise en place et de maintenance | à l'usage, voir ci-dessous | **Coût Gemini, payé une seule fois par document** — Gemini compte 258 tokens par page en entrée, mais c'est l'output (le texte extrait, ~700 tokens/page) qui domine. Pour 100 pages : | Modèle | Coût | |---|---| | Flash-Lite (déjà utilisé) | ~0,03 $ | | Flash | ~0,18 $ | > Tarifs à revérifier avant implémentation, ils bougent. L'ordre de grandeur — quelques centimes par document — suffit à trancher : deux jours à faire marcher Tesseract dans l'image Docker coûtent plus cher que des années d'OCR Gemini. L'argument confidentialité en faveur de Tesseract ne tient pas ici : le contenu part déjà chez Google à chaque `/ask`. ### Pièges d'implémentation identifiés 1. **Licence PDF** : prendre **PdfPig** (`UglyToad.PdfPig`, Apache 2.0). **Éviter iText7** — AGPL, incompatible avec un SaaS propriétaire sans licence commerciale payante. 2. **Ne pas utiliser `page.Text`** : rend les caractères dans l'ordre du fichier PDF, ce qui mélange les colonnes en bouillie sur toute mise en page multi-colonnes (= toutes les brochures). Utiliser `ContentOrderTextExtractor`. 3. **Fichier temporaire, pas `MemoryStream`** : une brochure de musée fait couramment 100-200 Mo. `ms.ToArray()` met tout sur le Large Object Heap. Ouvrir PdfPig depuis le chemin. 4. **Découper les appels OCR** : impossible de demander 70 000 tokens de sortie en un appel. Lots de 5-10 pages, N appels séquencés dans le job. 5. **API Gemini native pour l'ingestion** : le backend parle à Gemini via la couche de compatibilité OpenAI (`Microsoft.Extensions.AI.OpenAI`), qui gère mal l'input PDF. Prévoir un appel HTTP direct à l'API Gemini pour l'OCR, en gardant `IChatClient` pour `/ask`. 6. **Plafond dur** (~50 Mo / 300 pages) avec message clair, sinon un client uploadera son catalogue de 800 pages. 7. **Entité `ContentEmbedding` à étendre** : le plan pgvector suppose 1 contenu = 1 vecteur. Un PDF = N chunks. Ajouter `ChunkIndex`, `PageNumber` (pour citer « brochure.pdf, p.12 ») et `Language`. L'upsert doit devenir *supprime tous les chunks de ce ContentId puis réinsère* — sinon un PDF réuploadé en version plus courte laisse des chunks fantômes qui continuent de remonter dans les réponses. 8. **Suppression** : `ResourceController.Delete` nettoie les références dans les sections mais devra aussi purger les embeddings. (Au passage : il ne supprime pas non plus le blob Firebase — orphelin connu.) ### Points produit - **Ne rien indexer par défaut.** Un client uploadera son plan tarifaire interne, son planning d'équipe, un dossier de subvention — et le guide les récitera au premier visiteur qui pose la bonne question. Case à cocher explicite par ressource (`IncludeInAiKnowledge`), **désactivée par défaut**, libellée sans ambiguïté : « le guide pourra citer ce document aux visiteurs ». - **Rendre l'asynchrone visible.** Le client upload 200 pages, teste 10 secondes après, ne trouve rien, conclut que c'est cassé. Statut d'indexation par ressource (`Pending` / `Indexed` / `Failed` + message) affiché dans manager-app. - **Scans illisibles** : ne jamais indexer du vide silencieusement. Si l'extraction ET l'OCR échouent, remonter une erreur explicite. --- ## Infrastructure & dimensionnement serveur (décidé 2026-08-07) ### Ce qu'il faut ajouter à la stack **Une seule chose** : l'extension pgvector dans Postgres. L'image actuelle `postgis/postgis:16-3.4` ne l'embarque pas — le `CREATE EXTENSION vector` échouera tel quel. Petit Dockerfile (le dépôt PGDG est déjà configuré dans l'image) : ```dockerfile FROM postgis/postgis:16-3.4 RUN apt-get update \ && apt-get install -y --no-install-recommends postgresql-16-pgvector \ && rm -rf /var/lib/apt/lists/* ``` Aucun autre service, aucun conteneur supplémentaire. ### Dimensionnement Le point clé : **les embeddings et l'OCR sont générés par l'API Google, pas en local**. Donc **aucun GPU, aucun CPU lourd**. La machine ne fait que stocker des vecteurs et calculer des distances cosine. | Échelle | Vecteurs | Taille (768 dims × 4 o = 3 Ko/vecteur) | |---|---|---| | 1 venue (200 POIs × 3 chunks × 10 langues) | ~6 000 | ~18 Mo | | 10 venues | ~60 000 | ~200 Mo | | 100 venues | ~600 000 | ~1,8 Go (+ ~50 % pour l'index HNSW) | - **Jusqu'à ~30 venues** : la VM actuelle suffit. Compter **+1 à 2 Go de RAM** pour que l'index HNSW reste en cache Postgres. - **Cible confortable OVH** : VPS **4 vCPU / 8 Go / 80-160 Go SSD** (~15-25 €/mois). Y loger Postgres+pgvector, manager-service, Hangfire, Traefik. - **`maintenance_work_mem`** à monter (512 Mo – 1 Go) pour la construction de l'index HNSW. Ponctuel, pas du runtime. - **Alternative** : Public Cloud Databases PostgreSQL managées OVH — vérifier que pgvector est dans leur liste d'extensions pour la version cible. Éviterait de gérer les backups à la main (cf. TODO `pg_dump` toujours ouvert). ### Attention : Hangfire tourne in-process `services.AddHangfireServer()` démarre les workers **dans le process manager-service** — l'ingestion PDF consomme donc le CPU et la RAM du conteneur qui sert aussi l'API. Par défaut Hangfire lance `min(nbCPU × 5, 20)` workers : sur 4 vCPU, **20 jobs en parallèle**. Trois clients uploadant des PDF de 150 Mo simultanément suffisent à saturer la RAM. Queue dédiée et bridée pour l'ingestion : ```csharp services.AddHangfireServer(options => { options.Queues = new[] { "ingestion" }; options.WorkerCount = 2; }); ``` Et sur le job : `[Queue("ingestion")]` + `[AutomaticRetry(Attempts = 2)]` — le retry par défaut de Hangfire est à **10 tentatives**, ce qui sur un PDF corrompu facture 10 passes d'OCR Gemini. ### Coûts récurrents Le coût n'est pas la machine, c'est l'usage API : | Poste | Ordre de grandeur | Fréquence | |---|---|---| | Embedding d'un PDF 100 pages | ~0,01 $ | une fois à l'upload | | OCR Gemini d'un PDF 100 pages scanné | ~0,03 $ (Flash-Lite) | une fois, si scan | | Stockage vecteurs d'un PDF 100 pages | ~450 Ko dans Postgres | — | | Question visiteur (`/ask`) | Gemini + TTS | **à chaque question** ← le vrai poste | D'où l'intérêt du TTS pré-généré ci-dessous. L'infrastructure de quota tokens par plan existe déjà côté `SubscriptionPlan`. --- ## TTS Pré-généré (optimisation tokens & latence) ### Principe - À la sauvegarde d'un POI → déclenche la génération TTS → stocke le fichier MP3 - Les lunettes/app **streament le fichier** plutôt que d'appeler le TTS à chaque requête - Généré **par langue** et **par persona configuré** ### Avantages - Coût quasi zéro à l'usage (payé une fois à la création) - Fonctionne offline (téléchargement préalable possible) - Latence nulle pour le contenu "script de visite" ### Limite - Les **questions libres** des visiteurs restent en TTS dynamique (inévitable) - Toute modification du contenu = re-génération du MP3 --- ## Extension : Génération de visites Sur base du contenu indexé dans Qdrant + métadonnées des POIs : - **Visite linéaire** : "génère un parcours de 45min pour familles avec enfants" - **Visite thématique** : "uniquement les œuvres flamandes du 17e siècle" - **Visite dynamique** : Léon s'adapte aux questions en temps réel, rebondit sur les intérêts du visiteur Techniquement : prompt structuré + requête RAG sur la collection du venue. --- ## Roadmap suggérée | Version | Fonctionnalité | |---------|---------------| | V1 | Persona simple : system prompt + réponses RAG sur contenu existant | | V2 | TTS pré-généré par POI/langue/persona + visite générée à la demande + Avatar parlant (lipsync) | | V3 | Visite adaptative : Léon mémorise les préférences durant la session | --- ## Delta technique à implémenter 0. **Image Docker Postgres + pgvector** : prérequis à tout le reste, l'image `postgis/postgis:16-3.4` actuelle n'a pas l'extension 0 bis. **Médias & stockage** : compression client, `StoragePath` + `IStorageService`, quota, backfill — prérequis à la release de migration (voir `media-storage-plan.md`) 1. **Intégration pgvector** : migration EF Core, entité `ContentEmbedding` (avec `ChunkIndex` / `PageNumber` / `Language`), index HNSW, pipeline d'ingestion Hangfire au save du CMS (voir `rag-pgvector-integration-plan.md`) 2. **Champ "persona config"** par venue dans le CMS — modèle dual-persona Viva/Marco, voix + prompt par persona 3. **Endpoint RAG `/ask`** : vectorisation question + retrieval + génération Gemini, paramètre `wakewordId` 4. **Ingestion PDF** : queue Hangfire dédiée, PdfPig + cascade OCR Gemini, flag `IncludeInAiKnowledge` et statut d'indexation par ressource 5. **Pipeline TTS pré-généré** : trigger au save POI, stockage Firebase, gestion multi-langue/multi-persona (voir `tts-pregenerated-plan.md`) 6. **Canal Flutter Ray-Ban** : consomme les mêmes endpoints (déjà abstraits derrière interfaces) 7. **Avatar parlant (Talking Head)** : 3 frames PNG par persona, lipsync via timestamps Google TTS (voir `talking-head-plan.md`) --- ## Avatar Parlant — Talking Head (V2) ### Concept Portrait 2D de l'assistant animé avec lipsync synchronisé au TTS. Rendu dans l'app mobile / borne. Hors scope sur smart glasses (pas d'écran). ### Assets nécessaires par persona 3 variantes du portrait (générées via DALL-E 3) : - Bouche fermée (idle / fin de phrase) - Bouche mi-ouverte - Bouche ouverte (voyelles) ### Prompts DALL-E 3 **1 — Personnage de base** ``` 2D cartoon illustration of a friendly medieval knight named Léon, warm smile, mouth closed, facing slightly to the right, bust portrait, clean white background, consistent art style, flat colors, soft shading. Reference sheet style. ``` **2 — Mi-ouverte** ``` Same character as the reference image, exact same art style, same colors, same angle, same lighting, mouth slightly open as if mid-speech. ``` **3 — Ouverte** ``` Same character as the reference image, exact same art style, same colors, same angle, same lighting, mouth open as if speaking a vowel sound. ``` > Toujours envoyer l'image de référence avec les prompts 2 et 3 pour garantir la cohérence. ### Deux modes de lipsync **Mode simple — loop** - Boucle les 3 frames à ~80-100ms chacune pendant la lecture audio - Stop sur "fermée" quand l'audio s'arrête - ~30 lignes Flutter, zéro dépendance **Mode avancé — word timestamps (recommandé V2)** - Google Cloud TTS avec `enable_time_pointing` → retourne timestamps par **mot** (pas phonème) - Approximation côté Flutter : analyse des premières lettres du mot pour choisir la frame - Commence par `M, B, P` → bouche fermée - Voyelle `A, E, I, O, U` → bouche ouverte - Reste → mi-ouverte - Lipsync automatique depuis le texte, sans coût supplémentaire ### Pipeline automatisé complet ``` Config venue : persona prompt + style visuel ↓ DALL-E 3 génère les 3 variantes (bouton "Générer l'avatar" dans le CMS) ↓ Stockées dans Firebase Storage ({instanceId}/personas/{wakewordId}/frame_{0,1,2}.png) ↓ À la lecture : Google TTS → audio + word timestamps → Flutter anime les frames ``` ### Coût récurrent - DALL-E 3 : ~$0.04 × 3 images = ~$0.12 par persona créé (one-shot) - Google TTS avec timestamps : tarif standard TTS, pas de surcoût --- ## Notes pour session Claude Code - Stack existante : Flutter + **OpenWakeWord** + Google STT/TTS + Gemini Flash via .NET backend - Tous les composants sont abstraits derrière interfaces (swappable) - Qdrant déjà évalué pour MyAssistant — finalement remplacé par pgvector (voir `v2/rag-pgvector-integration-plan.md`). **Décision close, ne pas rouvrir** : justification chiffrée dans la section Architecture RAG - Aucun modèle IA ne tourne en local (embeddings, OCR, TTS, génération = API Google) → **pas de GPU, pas de machine spécifique** - Vérifier le modèle d'embedding avant de figer la dimension dans la migration EF : changer de modèle après coup = re-embed complet + migration de colonne. `gemini-embedding-001` sort en 3072 dims mais est réductible (MRL) à 768 — tronquer dès le départ permet de garder la colonne - Priorité : clôturer la version actuelle Ray-Ban Meta avant d'attaquer ce chantier - Modèle à deux personas retenu : "Viva" (féminin) + "Marco" (masculin), wakewords pré-entraînés inclus dans tous les plans. Persona custom (nom différent) = add-on facturable (effort entraînement OpenWakeWord + intégration build)