--- type: documentation project: NAV V2 created: 2026-04-14 status: validé session: S3 --- # PIPE-IA-DOC — Pipeline enrichissement IA NAV V2 Documentation précise du pipeline IA d'enrichissement des fiches NAV. Base pour le futur skill `/mistral-nemo-vps`. --- ## 1. Vue d'ensemble ``` Fiche soumise (moderation_status=pending, ai_processed=false) ↓ [WORKER] toutes les 5 min via systemd timer ↓ SCRAPING — crawl4ai AsyncHTTPCrawlerStrategy (mode statique, sans Playwright) ↓ TRUNCATURE — 16 000 chars max (~4 000 tokens) ↓ MISTRAL NEMO — open-mistral-nemo, temp=0.2, max_tokens=800, json_object ↓ NORMALISATION TAGS — mapping taxonomie interne ↓ UPDATE NocoDB — description_enrichie, points_cles, tags_fonction, moderation_status=ai_processed ↓ LOG stats_usage — tokens_in, tokens_out, cout_eur, orga_id ``` --- ## 2. Input — Schéma fiche entrant ### Champs lus depuis NocoDB (table `organisations`, `m08t7g5v4wch6wb`) | Champ | Type | Rôle dans la pipe | |-------|------|------------------| | `Id` | int | Identifiant unique, passé à stats_usage comme orga_id | | `nom` | text | Passé dans le user prompt | | `url` | text | URL à scraper (si présente et scrape_status=pending) | | `description` / `description_user` | longtext | Fallback si scrape échoué ou URL absente | | `echelle` | select | Passé au prompt pour contexte | | `scrape_status` | select | Détermine si on scrape (pending) ou non | | `ai_processed` | checkbox | false = à traiter | | `moderation_status` | select | pending = à traiter | ### Filtre NocoDB ``` GET /api/v1/db/data/noco/{BASE}/{TABLE}? where=(moderation_status,eq,pending)~and(ai_processed,eq,false) &limit=5 &sort=submitted_at ``` --- ## 3. Scraping — crawl4ai mode HTTP statique ### Configuration ```python from crawl4ai import AsyncWebCrawler, CrawlerRunConfig from crawl4ai.async_crawler_strategy import AsyncHTTPCrawlerStrategy strategy = AsyncHTTPCrawlerStrategy() run_cfg = CrawlerRunConfig( word_count_threshold=20, excluded_tags=['nav', 'footer', 'script', 'style', 'head'], remove_overlay_elements=True ) ``` ### Points clés - Mode **statique uniquement** — pas de Playwright, pas de Chrome (Playwright non installé sur le VPS Hetzner CAX11) - Timeout : **3 minutes** (spawnSync avec timeout=180 000 ms) - Troncature : **16 000 chars max** après récupération (~4 000 tokens Nemo) - Fallback : si échec, flag `scrape_status=failed` et appel Mistral avec `description_user` seule - Script Python exécuté via `spawnSync('python3', [scriptPath])` depuis Node.js ### Limitations connues - Les SPAs (Angular, React sans SSR) peuvent retourner du HTML vide → `scrape_status=failed` - Les sites avec RGPD wall (consent redirect) → `scrape_status=failed` - Crawl4ai 0.8.6 sur VPS : mode statique uniquement. Si besoin de JS-rendering → installer Playwright (`playwright install chromium`) et basculer sur `AsyncWebCrawler` standard. --- ## 4. Appel Mistral Nemo — Prompt exact ### Paramètres API ```json { "model": "open-mistral-nemo", "temperature": 0.2, "max_tokens": 800, "response_format": { "type": "json_object" } } ``` ### System prompt (copie exacte, source F §3) ``` Tu es un assistant spécialisé dans l'écosystème professionnel de l'architecture en France. Tu reçois des informations sur une organisation ou ressource liée au secteur de l'architecture, et tu dois les enrichir pour alimenter une cartographie collaborative. RÈGLES ABSOLUES : 1. Tu ne dois JAMAIS inventer d'informations non présentes dans les sources fournies. 2. Si une information est absente ou incertaine, retourne `null` pour ce champ. 3. Tu dois retourner UNIQUEMENT un objet JSON valide, sans texte avant ou après. 4. La description_enrichie doit être neutre, factuelle, en français, sans jugement de valeur. 5. Les points_cles sont des phrases courtes (max 12 mots chacune), actionnables pour un architecte. 6. Pour les tags_fonction, ne propose que des valeurs parmi la liste autorisée. TAXONOMIE AUTORISÉE : - Échelle (une seule valeur) : "National" | "Régional" | "Départemental" | "Local" - Territoire (une seule valeur) : "Métropole" | "Guadeloupe" | "Martinique" | "Guyane" | "Réunion" | "Mayotte" | null - Tags fonction (1 à 5 valeurs) : "Juridique" | "Technique" | "Économique" | "Administratif" | "Chantier" | "Comptabilité" | "Développement" | "Formation" | "Gestion d'agence" | "Santé mentale" FORMAT DE SORTIE JSON : { "description_enrichie": "string (max 300 chars, français, neutre, factuel)", "points_cles": ["string", "string", "string"], "tags_fonction": ["Valeur1", "Valeur2"], "echelle": "National" | "Régional" | "Départemental" | "Local" | null, "territoire": "Métropole" | ... | null, "localisation_ville": "string" | null, "confiance": "haute" | "moyenne" | "faible" } Le champ "confiance" reflète ta certitude globale sur l'enrichissement : - "haute" : URL scrapée avec contenu riche, informations claires - "moyenne" : URL scrapée mais contenu partiel, ou description_user seule suffisante - "faible" : URL non disponible et description_user vague, inférences importantes ``` ### User prompt (template) ``` ORGANISATION À ENRICHIR : Nom : {{nom}} URL : {{url_ou_"non fournie"}} Description soumise par l'utilisateur : {{description_user_ou_"non fournie"}} CONTENU DU SITE WEB (extrait par scraping) : {{scrape_content_ou_"Site non accessible ou URL non fournie."}} --- Enrichis cette fiche selon les règles du system prompt. Retourne uniquement le JSON. ``` --- ## 5. Output — Champs mis à jour dans NocoDB | Champ NocoDB | Source | Exemple | |-------------|--------|---------| | `description_enrichie` | JSON IA `.description_enrichie` | "Le CNOA est un organisme réglementaire..." | | `points_cles` | JSON.stringify(IA `.points_cles`) | `["Représenter les architectes","..."]` | | `tags_fonction` | Tags normalisés, joint par virgule | `"Juridique,Administratif"` | | `echelle` | IA `.echelle` (si non renseignée) | `"National"` | | `territoire` | IA `.territoire` (si non renseignée) | `"Métropole"` | | `localisation_ville` | IA `.localisation_ville` (si vide) | `"Paris"` | | `moderation_status` | Fixé à `"ai_processed"` | — | | `ai_processed` | Fixé à `true` | — | | `ai_raw_output` | JSON.stringify complet du retour IA | Debug | | `scrape_status` | `"scraped"` / `"failed"` / `"no_link"` | — | | `scrape_content` | Markdown brut crawl4ai | Stocké pour debug | ### Normalisation tags Les tags retournés par l'IA sont normalisés via un mapping interne avant insertion. L'apostrophe `'` (U+0027) est convertie en `'` (U+2019) pour compatibilité NocoDB. Tags non reconnus → ignorés silencieusement. --- ## 6. Circuit breaker budget ### Calcul coût Mistral Nemo ``` cout_eur = ((tokens_in × 0.02 + tokens_out × 0.04) / 1_000_000) × 0.93 ``` (Prix USD/1M tokens × taux USD→EUR fixé à 0.93) ### Paliers | Seuil | Action | |-------|--------| | ≥ €20 | Hard stop, email Jules, worker en pause | | Budget OK | Vérification avant chaque fiche | ### Limitation connue Le filtre NocoDB par date (`gte,YYYY-MM-DD`) n'est pas supporté en v0.301.5. Contournement : récupération de tous les records stats_usage (limit=1000) et filtre JavaScript par mois/année. --- ## 7. Infrastructure ### Fichiers | Fichier | Chemin VPS | Description | |---------|-----------|-------------| | Worker | `/opt/nav-carte/worker/enrich.js` | Script Node.js principal | | Config | `/opt/nav-carte/.env` | Variables (chmod 600) | | Lock | `/tmp/nav-worker.lock` | Anti-overlap | | Timer | `/etc/systemd/system/nav-worker.timer` | Cron 5 min | | Service | `/etc/systemd/system/nav-worker.service` | Oneshot systemd | ### Variables .env utilisées ``` MISTRAL_API_KEY=... NOCODB_URL=http://localhost:8070 NOCODB_TOKEN=... NOCODB_BASE=pipilvsi7dibo80 NOCODB_TABLE_ORGAS=m08t7g5v4wch6wb NOCODB_TABLE_STATS=mbbq7n47ixy19mc RESEND_API_KEY=... RESEND_FROM=contact@trans-former.fr EMAIL_JULES=jules@trans-former.fr BUDGET_MAX_EUR=20 WORKER_LIMIT=5 ``` ### Commandes utiles ```bash # Voir les logs du worker journalctl -u nav-worker.service -n 50 --no-pager # Voir le timer systemctl status nav-worker.timer # Lancer manuellement cd /opt/nav-carte/worker && node --env-file=/opt/nav-carte/.env enrich.js # Lancer avec limite custom WORKER_LIMIT=1 node --env-file=/opt/nav-carte/.env enrich.js ``` --- ## 8. Résultats des 3 fiches test (Session 3 — 2026-04-14) ### Métriques | Fiche | NocoDB Id | Scrape | tokens_in | tokens_out | cout_eur | Temps | Confiance | |-------|-----------|--------|-----------|------------|----------|-------|-----------| | CNOA | 106 | 1 506 chars | 1 248 | 167 | €0.000029 | 3.0s | haute | | Archireport | 107 | 13 414 chars | 4 376 | 261 | €0.000091 | 3.9s | haute | | Collectif Fil | 108 | 6 521 chars | 2 628 | 221 | €0.000057 | 4.3s | haute | | **Total** | — | — | **8 252** | **649** | **€0.000177** | **11.2s** | — | **Extrapolation 96 fiches :** €0.000177 × (96/3) = **€0.0057** total (très loin du seuil €1) ### Qualité des enrichissements **CNOA (qualité : 4/5)** - `description_enrichie` : neutre, factuelle, 200 chars. Correct. - `points_cles` : 4 items. Pertinents mais génériques (niveau d'abstraction élevé). - `tags_fonction` : Juridique, Administratif. L'IA a conservé uniquement les tags justifiés par le contenu scraped (règle "ne pas inventer" respectée). Manque "Gestion d'agence" qui était dans la fiche Jules mais pas dans le contenu scraped. - Observation : le site architectes.org retourne peu de contenu (navigation + accroche = 1 506 chars). Performance correcte compte tenu du contexte limité. **Archireport (qualité : 5/5)** - `description_enrichie` : précise, factuelle, 302 chars. Très bonne. - `points_cles` : 7 items très actionnables pour un architecte (gestion réserves, rapports, lots). - `tags_fonction` : Technique, Administratif, Chantier. Pertinent. L'IA a ajouté "Administratif" non présent dans la fiche Jules → justifié (diffusion rapports de chantier = dimension administrative). - Scrape : 13 414 chars = contenu riche. Nemo a bien distillé. **Collectif Fil (qualité : 4/5)** - `description_enrichie` : riche, capture l'esprit du collectif (architecture, urbanisme, participatif). 310 chars (légèrement au-dessus de 300, acceptable). - `points_cles` : 3 items très qualitatifs (urbanisme participatif, méthodologies, co-construction). - `tags_fonction` : Juridique, Technique, Économique, Administratif, Formation. 5 tags — un peu large. "Juridique" et "Économique" sont discutables pour un collectif de recherche-action. À revoir lors de la modération. - Observation : l'IA semble sur-tagger quand le contenu est riche et varié. Acceptable — Jules valide en modération. ### Analyse comparative fonctions Jules vs IA | Fiche | Fonctions Jules (seed) | Fonctions IA | Delta | |-------|----------------------|-------------|-------| | CNOA | Juridique, Administratif, Gestion d'agence | Juridique, Administratif | IA perd Gestion d'agence (non dans scrape) | | Archireport | Chantier, Technique | Technique, Administratif, Chantier | IA ajoute Administratif (justifié) | | Collectif Fil | Technique, Développement | Juridique, Technique, Économique, Administratif, Formation | IA sur-tague (5/5) | **Observation générale :** l'IA est conservatrice sur les fiches avec peu de contenu (bon comportement) et peut sur-tagger avec du contenu riche. Le workflow Jules valide en modération NocoDB UI est adapté. --- ## 9. Problèmes rencontrés et solutions | Problème | Solution appliquée | |---------|-------------------| | Playwright absent sur VPS → crawl4ai crash | Utilisation de `AsyncHTTPCrawlerStrategy` (mode statique) | | NocoDB rejette filtres datetime ISO | Récupération de tous les records + filtre JavaScript par mois | | NocoDB rejette apostrophe U+0027 dans tags | Utilisation apostrophe typographique U+2019 partout | | `import('child_process')` dynamique en ESM | Remplacement par `spawnSync` importé statiquement en haut | | Budget check fonctionne mais log "Erreur" | Corrigé dans version finale | --- ## 10. Base skill `/mistral-nemo-vps` (future) Ce pipeline est documenté pour servir de base au skill `/mistral-nemo-vps` : - **Entrée :** fichier texte ou URL → scraping crawl4ai → prompt enrichissement - **Sortie :** JSON structuré (description, points clés, tags, confiance) - **Paramètres configurables :** model, temperature, max_tokens, taxonomie, seuil confiance - **Réutilisable pour :** audit fiche archi, résumé document, tagging automatique Pattern à généraliser : ``` 1. Scrape(url) → markdown tronqué 2. Prompt(system_prompt, user_prompt_template, fiche_data, scrape_content) 3. Parse(json_response) → champs structurés 4. Normalize(tags) → taxonomie contrôlée 5. Log(usage) → stats_usage ``` --- ## 11. B5 (2026-09-27) — Formulaire assoupli + pipe adapté (fusion, pas réécriture) Ce qui suit documente les écarts posés par le batch B5 (décisions de la maîtrise d'œuvre du 27/09). Les sections 1 à 10 ci-dessus restent la référence de fond (taxonomie, système prompt d'origine, circuit breaker) ; cette section documente ce qui a changé dans `worker/enrich.js` et le pipe de soumission (`server/api/submit/`). ### 11.1 Mapping soumission libre → colonnes NocoDB existantes (M1) Le formulaire `/proposer` assoupli (`components/FormLibre.vue`, `utils/submitLibre.ts`) écrit dans la table orgas existante, **aucune colonne nouvelle** : | Champ formulaire | Colonne NocoDB | Règle | |---|---|---| | 1er lien de la liste | `url` | `null` si aucun lien | | texte « pourquoi » + tous les liens | `description_user` | `{texte}\n\nLiens :\n{lien1}\n{lien2}...` — et si aucun type suggéré, la ligne `Type : non précisé` est préfixée en tête | | — | `nom` | hostname du 1er lien, ou 60 premiers caractères du texte si aucun lien, préfixé `[à qualifier] ` | | chip type suggéré | `submission_type` | valeur choisie (`ecosysteme`/`reseau`/`job`/`outil`) ; si aucune chip → `ecosysteme` par défaut (voir ligne `Type : non précisé` ci-dessus) | | chip « Références » | *(table `ressources_references`, pas orgas)* | `titre`/`auteur` placeholders, `description` = texte + liens, comme les autres | | email (optionnel) | `submitted_by_email` | — | **À faire au déploiement** : ajouter l'option `libre` à la liste de valeurs acceptées par `submission_type` si l'on veut un jour distinguer une soumission libre d'une soumission via un des 5 formulaires détaillés — non fait ici pour ne pas tester un ALTER contre la prod sans l'avoir vérifié. ### 11.2 Worker — écarts (M2) | Sujet | NAV V2 (§1-10 ci-dessus) | AEP B5 | |---|---|---| | LLM | Mistral Nemo, appel direct | **Bifrost** (`${BIFROST_URL}/v1/chat/completions`, header `x-bf-vk`), modèle `WORKER_MODEL` (défaut `groq/openai/gpt-oss-20b` depuis le 28/09, voir §11.3) | | Scrape | crawl4ai (Python, `AsyncHTTPCrawlerStrategy`) | **fetch natif Node 22** — timeout 8 s, corps plafonné 500 Ko, extraction titre + meta description + `og:*` + texte visible tronqué à 4000 caractères, `User-Agent: AEP/2.0 contact@trans-former.fr` | | Notification | Resend (email Jules) | **ntfy** (`POST https://ntfy.sh/$NTFY_TOPIC`) — le message ne contient JAMAIS l'email ni le texte libre du contributeur, seulement id NocoDB / nom suggéré / type / confiance | | Seuil « 5 fiches pending » | Email si ≥ 5 en attente | **Retiré** (décision MOE : volume faible, une notif par fiche traitée suffit — à réactiver si le volume monte) | | Sortie JSON du LLM | `description_enrichie, points_cles, tags_fonction, echelle, territoire, localisation_ville, confiance` | `{nom, description, type_suggere, ville, tags[], confiance}` — le worker écrit ensuite dans les colonnes existantes (`description_enrichie`, `tags_fonction`, `localisation_ville`) | | Écriture de `nom` | jamais réécrit | réécrit **seulement** si le nom actuel commence par `[à qualifier]` (placeholder posé par le formulaire assoupli) | | Écriture de `submission_type` | jamais réécrit | réécrit **seulement** si `description_user` commence par `Type : non précisé` (l'utilisateur n'a pas choisi de chip) et que le type suggéré par le LLM est valide | | Prix des tokens | fixe (prix Mistral Nemo) | variables `WORKER_PRICE_IN_USD_PER_M` / `WORKER_PRICE_OUT_USD_PER_M`, défaut **0** (tier Groq du tier RAPIDE Bifrost, gratuit) | | Lock | `/tmp/nav-worker.lock` | `/tmp/aep-worker.lock` (configurable `WORKER_LOCK_FILE`) | | Chemin VPS | `/opt/nav-carte/worker/` | `/opt/aep-worker/` (voir `deploy/aep-worker/README.md`) | | Timer | 5 min | 15 min (`deploy/aep-worker/aep-worker.timer`) | Le mode `--dry-run` (`node enrich.js --dry-run`) lit `worker/fixtures/dry-run-rows.json` au lieu de NocoDB, n'écrit rien, et par défaut mock aussi le scrape et l'appel Bifrost (`DRY_RUN_LIVE=1` pour forcer de vrais appels réseau sans jamais toucher NocoDB). ### 11.3 Modèles Groq retirés — bascule sur gpt-oss (2026-09-28) Groq ne sert plus `llama-3.1-8b-instant` ni `llama-3.3-70b-versatile` (404 `model_not_found` via Bifrost, constaté au test M3 du 28/09). | Consommateur | Avant | Après | Où | |---|---|---|---| | Worker (`worker/enrich.js`) | `groq/llama-3.1-8b-instant` | `groq/openai/gpt-oss-20b` | prod : `WORKER_MODEL` dans `/opt/aep/.env` depuis le 28/09 ; défaut du code aligné | | Chatbots, tier RAPIDE | `groq/llama-3.1-8b-instant` | `groq/openai/gpt-oss-20b` | `server/utils/bifrost.ts`, replis inchangés | | Chatbots, tier APPROFONDI | `groq/llama-3.3-70b-versatile` | `groq/openai/gpt-oss-120b` | `server/utils/bifrost.ts`, replis inchangés | | RAG Pensées (LightRAG) | `gemini-oai/models/gemini-2.5-flash-lite` | inchangé | `/opt/lightrag/.env`, hors de ce routage | Entre le retrait et ce correctif, chaque appel chatbot partait sur le premier repli Gemini (`stats_usage` depuis le 23/09 environ) : service rendu, modèle primaire jamais servi. **gpt-oss est un modèle à raisonnement.** Deux conséquences : - la réponse porte `choices[0].message.reasoning` à côté de `content`. Les trois routes (`chatbot`, `chatbot-reseaux`, `chatbot-taff`) et le worker ne lisent que `content` (JSON), `usage` et `extra_fields` : le champ est ignoré, rien n'en dépend. `chatbot-pensees` lit la réponse de LightRAG (`response`, `references`), pas celle de Bifrost ; - les tokens de raisonnement sont décomptés de `max_tokens`. Si le fournisseur rend un JSON tronqué en HTTP 200, Bifrost ne bascule pas et l'usager lit « Je n'ai pas pu analyser ta demande » (s'il le rejette en 400, le repli Gemini prend le relais : dégradé, pas cassé). Les routes chatbot ajoutent donc `REASONING_MARGIN_TOKENS` (800) à leur budget de contenu (600 ou 700), et `bifrostContent()` écrit un avertissement au journal quand `finish_reason` vaut `length`. Le worker garde `max_tokens: 800` (sortie courte, M3 passé en 1,6 s) ; à relever si `tokens_out` approche 800 dans `stats_usage`. **Non testé depuis Windows** (Bifrost écoute sur le loopback du VPS). Test réel au checkpoint : un appel par route, relever `extra_fields` (modèle réellement servi), `usage.completion_tokens`, `finish_reason`, et le modèle inscrit dans `stats_usage` pour `/api/chatbot`.