Skip to main content

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/<slug>-<timestamp>.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-<slug>.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-<slug>.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_<slug>)
   │   └─ 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-<slug>.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/<slug>/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 :

  1. Ligne games créée avec ingestStatus='scheduled' + ingestScheduledAt
  2. PDF écrit sur disque
  3. setTimeout armé dans cron/ingest-scheduler.ts
  4. Au boot : restoreScheduledOnBoot() re-programme tous les timers en attente
  5. 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 :

  1. Cron à BGG_FORUM_SYNC_HOUR (défaut 3h)
  2. 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_<slug> avec is_forum_chunk: true
  3. 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.