Flux d'ingestion (PDF → Qdrant) Flux d'ingestion (PDF → Qdrant) Dernière mise à jour : 2026-05-10 Source de vérité : ARCHITECTURE.md § "Flux d'ingestion". 11 étapes POST /api/games/ingest [multipart : PDF + metadata] │ 1. Upload PDF → /app/pdfs/-.pdf │ └─ Choix type : base / extension / advanced_rules / faq │ 2. Extraction texte (pdfjs-dist → RawPage[]) │ 3. OCR conditionnel (services/ocr/) │ ├─ decideOCR(rawPages) : avg chars/page < OCR_AUTO_THRESHOLD ? │ │ ├─ disabled (OCR_ENABLED=false) │ │ ├─ no_text (0 page texte → PDF entièrement scanné) │ │ ├─ low_text (couche texte cassée) │ │ └─ sufficient (skip OCR) │ ├─ Si shouldOCR=true : │ │ ├─ pdftoppm rend chaque page en PNG 300 DPI │ │ ├─ Pool tesseract (OCR_CONCURRENCY workers, défaut 2) │ │ ├─ Langues OCR_LANGUAGES (défaut fra+eng) │ │ ├─ Cache JSON ocr-v1-.json (sauvé tous les 3 pages) │ │ └─ Confiance log warn si < OCR_MIN_CONFIDENCE (40) │ └─ Réinjection texte OCR dans pipeline normal │ 4. Découpage sémantique (chunking/chunker.ts) │ └─ Paragraphes + overlap, fusion intelligente │ 5. Hierarchy LLM (hierarchy.ts) │ └─ Claude analyse le doc complet → arbre chapter > section > sub │ └─ Chaque chunk reçoit hierarchy_path + hierarchy_level │ 6. Contextual Retrieval B (contextual-llm.ts) │ ├─ Pour chaque chunk : Claude reçoit DOC + hierarchy_path │ ├─ Génère 1-2 phrases de contexte │ ├─ 10 SSH parallèles (pool worker) │ ├─ Cache JSON contexts-v2-.json (reprise après crash/quota) │ └─ Le contexte préfixe le chunk avant embedding │ 7. Embeddings TEI bge-m3 (batch 32) │ └─ Vecteur dense 1024 par chunk (préfixé par contexte LLM) │ 8. Upsert Qdrant (collection rules_) │ └─ Payload : hierarchy_path, page_start/end, is_extension, contextual_text, etc. │ 9. Progression SSE (jusqu'à 7 actes UI) │ ├─ Lecture │ ├─ [Reconnaissance optique] ← uniquement si OCR a tourné │ ├─ Découpage │ ├─ Structuration │ ├─ Analyse contextuelle │ ├─ Compréhension (embeddings) │ └─ Archivage │ 10. [Optionnel, extensions seulement] Détection conflits (conflict-detect.ts) │ ├─ Pour chaque chunk extension : recherche hybride dans le jeu de base │ ├─ Si similarité > CONFLICT_SIMILARITY_THRESHOLD (0.65) : │ │ └─ Claude SSH (CONFLICT_MODEL) classifie : replaces / modifies / extends │ ├─ 10 SSH parallèles │ ├─ Cache JSON conflicts-v1-.json │ └─ Annotations stockées dans payload Qdrant │ (conflict_type, conflict_base_chunk_id, conflict_summary, conflict_base_page) │ 11. Rendu PNG des pages (non-fatal) │ ├─ pdftoppm 300 DPI → /app/pdfs/images//page-XX.png │ ├─ Idempotent : skip si déjà rendu par étape 3 (OCR) │ └─ Échec → logger.warn, ingestion reste valide Patterns importants Coordinator + stages : services/ingest/coordinator.ts orchestre, chaque stage est un fichier dans stages/ avec signature (game, ..., push: EventPusher) => Promise<...>. Erreurs non-fatales : try/catch local dans le stage ou autour de l'appel dans le coordinator (cas de renderPdfPages). Reprise après quota : tous les stages SSH parallèles flushent le cache JSON avant de propager ClaudeQuotaError. Le coordinator transforme l'erreur en ingestStatus='scheduled' + scheduleIngestStart(gameId, retryAt) (pas 'error'). Auto-OCR transparent : pas de toggle UI. Si OCR_ENABLED=true (défaut) et seuil dépassé, l'OCR tourne. L'acte "Reconnaissance optique" n'apparaît dans l'UI que si l'OCR a effectivement tourné. Ingestion planifiée scheduled_start_at (ISO, max +7 jours) dans le POST : Ligne games créée avec ingestStatus='scheduled' + ingestScheduledAt PDF écrit sur disque setTimeout armé dans cron/ingest-scheduler.ts Au boot : restoreScheduledOnBoot() re-programme tous les timers en attente Si date passée pendant downtime → start immédiat avec grace 1s DELETE /api/games/:id/scheduled annule (clear timer + supprime PDF + supprime ligne). 409 si pas en scheduled. Forums BGG (cron quotidien) Si BGG_FORUM_SYNC_ENABLED=true : Cron à BGG_FORUM_SYNC_HOUR (défaut 3h) Pour chaque jeu avec bgg_id en BDD : getBggForumList() trouve le forum "Rules" getBggForumThreads() récupère threads (max 3 pages = ~150) Pour chaque thread : articles concaténés (~2000 chars max) Embed (dense + sparse) + upsert dans rules_ avec is_forum_chunk: true Rate-limit : 5s entre appels BGG Chunks forum identifiés par is_forum_chunk: true + tag [FORUM] dans le contexte RAG. Pas de page ( page_start: 0, page_end: 0). Filtrage par intent (depuis 2026-04-26) : chunks is_forum_chunk=true exclus quand intent ∈ {synergy, deckbuilding, meta}. Les forums sont conçus pour clarifier des règles, pas pour discuter deckbuilding.