# 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.