Files
nav-carte/PIPE-IA-DOC.md
T
Jules NenyandClaude Opus 5.5 6ac5a2bdf7 fix(budget): le circuit breaker des chatbots lit enfin stats_usage
checkBudget filtrait stats_usage sur `timestamp` (where + fields) et
recevait un 422 à chaque appel : fail-open silencieux, budget jamais
vérifié côté chatbot. Aligné sur le worker : aucune colonne nommée dans
la requête, lecture paginée, mois filtré en JS (timestamp, puis
CreatedAt, puis created_at). Chaque lecture journalise le budget lu ;
un échec journalise « budget NON vérifié » et renvoie verified=false.

Test : scripts/test-circuit-breaker.mjs, faux NocoDB qui rejette en 422
toute colonne absente (15/15 ; 11 échecs si le filtre revient).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0174RrDEQFQTtySXkTcsUKuv
2026-09-29 00:19:06 +02:00

20 KiB
Raw Blame History

type, project, created, status, session
type project created status session
documentation NAV V2 2026-04-14 validé 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

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

{
  "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.

Côté chatbot (AF5, 29/09) : server/utils/circuitBreaker.ts n'appliquait pas ce contournement. Il envoyait where=(timestamp,gte,…)&fields=cout_eur,timestamp et recevait un 422 à chaque appel : fail-open silencieux, budget jamais vérifié depuis la V2. Il suit désormais le worker : aucune colonne nommée dans la requête (ni where, ni fields, ni sort), lecture paginée (limit/offset 1000), mois filtré en JS sur timestamp, sinon CreatedAt, sinon created_at. Chaque lecture écrit au journal [circuitBreaker] budget lu : X € / 20 € (n lignes AAAA-MM sur N lues) ; un échec écrit budget NON vérifié. Test : node scripts/test-circuit-breaker.mjs (faux NocoDB qui rejette en 422 toute colonne absente). Seule /api/chatbot vérifie le budget ; -reseaux et -taff ne le lisent ni ne l'écrivent (asymétrie pré-existante), et les appels servis par un tier gratuit s'inscrivent à 0 €.


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

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