Files
nav-carte/V2-cadrage/F-spec-pipe-collaboration.md
2026-04-28 14:00:05 +02:00

37 KiB
Raw Blame History

NAV V2 — Spec technique : pipe de collaboration

Date : 2026-04-14 Statut : draft v1 — à valider avant implémentation Auteur : ATIS


1. Schéma ASCII global de la pipe

┌─────────────────────────────────────────────────────────────────────────────┐
│  SOUMISSION (mobile-first)                                                  │
│                                                                             │
│  [User remplit formulaire]                                                  │
│         ↓                                                                   │
│  POST /api/submit                                                           │
│   → validation Zod (nom requis, URL valide si fournie, email valide si fni) │
│   → rate check : ≤ 3 soumissions / IP / jour                               │
│   → NocoDB: INSERT row                                                      │
│     · moderation_status = pending                                           │
│     · scrape_status = pending (si URL) | no_link (si pas d'URL)            │
│     · ai_processed = false                                                  │
│   → HTTP 201 + message "Merci, fiche en cours de traitement"               │
└─────────────────────────────────────────────────────────────────────────────┘
         ↓
         (async — NE PAS bloquer la requête HTTP)
         ↓
┌─────────────────────────────────────────────────────────────────────────────┐
│  WORKER CRON (toutes les 5 min)                                             │
│                                                                             │
│  1. Fetch NocoDB: rows WHERE ai_processed=false AND moderation_status=pending│
│  2. Pour chaque row :                                                       │
│                                                                             │
│     a. [Si scrape_status=pending ET URL présente]                           │
│        → crawl4ai scrape (timeout 3 min)                                    │
│        → Si succès : scrape_status = scraped, contenu stocké               │
│        → Si échec  : scrape_status = failed → passer à l'étape d            │
│                                                                             │
│     b. [Budget check avant appel IA]                                        │
│        → Lire stats_usage: cumul coût_eur mois courant                      │
│        → Si ≥ 20€ : row laissée en pending, worker s'arrête                │
│                                                                             │
│     c. [Appel Mistral Nemo — enrichissement]                                │
│        → Prompt enrichissement (voir §3)                                    │
│        → Retry 2× si erreur réseau ou JSON mal formé                       │
│        → Si 3 échecs : ai_processed=false, moderation_status=ai_error      │
│        → Si succès  : parse JSON, update NocoDB fields                      │
│                                                                             │
│     d. [Update NocoDB]                                                      │
│        → ai_processed = true                                                │
│        → moderation_status = ai_processed                                  │
│        → description_enrichie, points_cles, tags_fonction, etc.            │
│        → ai_raw_output (debug)                                              │
│                                                                             │
│     e. [Log tokens]                                                         │
│        → INSERT stats_usage (tokens_in, tokens_out, coût_eur, model, ts)   │
│                                                                             │
│  3. Si N nouvelles fiches ai_processed : email Jules "N fiches à modérer"  │
└─────────────────────────────────────────────────────────────────────────────┘
         ↓
┌─────────────────────────────────────────────────────────────────────────────┐
│  MODÉRATION MANUELLE (Jules, review hebdo)                                  │
│                                                                             │
│  Interface : NocoDB UI (ou page admin custom)                               │
│  Jules filtre sur moderation_status = ai_processed                          │
│  Pour chaque fiche : Approve → published | Reject → rejected               │
│  moderated_at = NOW(), moderator_note optionnel                             │
└─────────────────────────────────────────────────────────────────────────────┘
         ↓
┌─────────────────────────────────────────────────────────────────────────────┐
│  FRONT                                                                      │
│                                                                             │
│  GET /api/orgs → liste fiches WHERE moderation_status=published            │
│  Carte Leaflet/MapLibre + filtres taxonomie                                 │
└─────────────────────────────────────────────────────────────────────────────┘

PIPE COMMENTAIRE (parallèle) :

[User soumet commentaire]
    → POST /api/comment
    → rate check : ≤ 5 / IP / jour
    → Budget check
    → Mistral Nemo : filtre éthique (prompt §4)
    → Si safe=true  : INSERT comment, published=true
    → Si safe=false : INSERT comment, published=false, flag pending review Jules
    → Log usage

2. Schéma NocoDB — champs complets table organisations

Table : m08t7g5v4wch6wb (base pipilvsi7dibo80)

Champ                   Type          Contraintes / Notes
─────────────────────────────────────────────────────────────────────────────
id                      auto-int      PK, auto-increment
nom                     text          required, max 200 chars
url                     text          nullable, validé comme URL si présent
description_user        longtext      saisie brute du soumetteur, nullable
description_enrichie    longtext      générée par IA, nullable jusqu'au traitement
points_cles             longtext      JSON array stringifié ["bullet1","bullet2",…]
                                      nullable jusqu'au traitement IA
echelle                 select        values: National | Régional | Départemental | Local
                                      nullable (IA propose, Jules valide)
territoire              select        values: Métropole | Guadeloupe | Martinique |
                                      Guyane | Réunion | Mayotte
                                      nullable
tags_fonction           multiselect   values: Juridique | Technique | Économique |
                                      Administratif | Chantier | Comptabilité |
                                      Prospection | RH | Santé mentale
                                      max 5 valeurs (MVP : pas d'ordre priorité,
                                      ordre = ordre de sélection)
localisation_ville      text          nullable, extraite par IA ou saisie user
latitude                decimal(9,6)  nullable, géocodage post-modération (hors MVP)
longitude               decimal(9,6)  nullable, idem
scrape_status           select        values: pending | scraped | failed | no_link
                                      default: pending si URL, no_link sinon
moderation_status       select        values: pending | ai_processed | ai_error |
                                      approved | rejected
                                      default: pending
ai_processed            checkbox      false par défaut, true après traitement worker
submitted_by_email      text          nullable, pas de validation format strict
                                      (tolérance typos côté UX)
submitted_at            datetime      auto NOW() à l'INSERT
moderated_at            datetime      nullable, rempli par Jules lors de la modération
moderator_note          text          nullable, note interne Jules
ai_raw_output           longtext      JSON brut retourné par Mistral, pour debug
                                      nullable, peut être purgé périodiquement
scrape_content          longtext      contenu markdown extrait par crawl4ai
                                      nullable, utilisé comme input IA puis archivé
─────────────────────────────────────────────────────────────────────────────

Table avis : m4hub7cdutgec47

Champ                   Type          Notes
─────────────────────────────────────────────────────────────────────────────
id                      auto-int      PK
orga_id                 int           FK → table organisations.id
contenu                 longtext      required
auteur_pseudo           text          nullable
auteur_email            text          nullable (non affiché publiquement)
published               checkbox      true si filtre éthique OK, false si pending
safe_check              select        values: safe | flagged | pending
                                      default: pending
safe_reason             text          nullable, raison du flag IA
submitted_at            datetime      auto NOW()
moderated_at            datetime      nullable
─────────────────────────────────────────────────────────────────────────────

Table stats_usage (nouvelle à créer)

Champ                   Type          Notes
─────────────────────────────────────────────────────────────────────────────
id                      auto-int      PK
model                   text          ex. "mistral-nemo-latest"
endpoint                text          ex. "enrichissement" | "filtre-ethique" | "chatbot"
tokens_in               int
tokens_out              int
cout_eur                decimal(8,6)  calculé : (in × prix_in + out × prix_out) / 1_000_000
timestamp               datetime      auto NOW()
orga_id                 int           nullable (lié à la fiche si enrichissement)
─────────────────────────────────────────────────────────────────────────────

3. Prompt Mistral Nemo — enrichissement fiche

Paramètres d'appel

model: "open-mistral-nemo"
temperature: 0.2
max_tokens: 800
response_format: { type: "json_object" }

System prompt (copier-coller tel quel)

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é" | "Prospection" | "RH" | "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 — substituer les variables)

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.

4. Prompt Mistral Nemo — filtre éthique commentaires

Paramètres d'appel

model: "open-mistral-nemo"
temperature: 0.0
max_tokens: 100
response_format: { type: "json_object" }

System prompt (copier-coller tel quel)

Tu es un modèle de modération de contenu. Tu analyses des commentaires soumis sur une plateforme professionnelle française dédiée aux architectes. Tu dois détecter les contenus problématiques.

CONTENUS À SIGNALER (safe=false) :
- Propos racistes, antisémites, islamophobes, xénophobes
- Propos sexistes, misogynes, homophobes, transphobes, LGBTQIA-phobes
- Propos validistes (mépris des personnes handicapées)
- Diffamation personnelle nominative (accusations graves sans fondement contre une personne identifiable)
- Spam publicitaire explicite (lien commercial non sollicité + texte promotionnel)
- Harcèlement ciblé, menaces explicites

CONTENUS À AUTORISER (safe=true) :
- Critiques d'organisations ou de services (même sévères), sans attaque personnelle
- Partages d'expériences professionnelles négatives factuelles
- Désaccords et débats professionnels
- Signalement d'erreurs ou d'informations inexactes

RÈGLES :
1. En cas de doute, favorise safe=true (la liberté d'expression professionnelle prime).
2. Le champ "category" est obligatoire si safe=false.
3. Le champ "reason" est une phrase courte en français (max 20 mots).
4. Retourne UNIQUEMENT un objet JSON valide.

FORMAT DE SORTIE :
{
  "safe": true | false,
  "category": "racisme" | "sexisme" | "homophobie" | "antisémitisme" | "lgbtqia-phobie" | "validisme" | "diffamation" | "spam" | "harcelement" | null,
  "reason": "string (max 20 mots)" | null
}

User prompt (template)

COMMENTAIRE À ANALYSER :

"{{contenu_commentaire}}"

Analyse ce commentaire et retourne le JSON de modération.

5. Mapping normalisation tags

Le worker utilise ce mapping pour corriger et compléter les tags suggérés par l'IA. L'IA peut nommer les tags librement dans son raisonnement interne, mais le worker applique cette normalisation avant d'écrire en base.

Entrée (variantes fréquentes)                → Tag normalisé
──────────────────────────────────────────────────────────────────────────────
JURIDIQUE
"droit du travail", "droit de l'urbanisme",  → Juridique
"litiges", "contrats MOE", "PI",
"propriété intellectuelle", "déontologie",
"marchés publics droit", "CCAG",
"responsabilité décennale", "médiation"

TECHNIQUE
"RE2020", "réglementation thermique",         → Technique
"structure", "BIM", "DTU",
"normes acoustiques", "performance énergétique",
"matériaux", "simulation thermique",
"OPR", "réserves chantier", "ACV",
"accessibilité PMR", "eurodes"

ÉCONOMIQUE
"prix", "tarifs", "honoraires", "devis",      → Économique
"ROI", "financement", "subventions",
"CEE", "MaPrimeRénov", "ANAH",
"business plan agence", "pricing MOE"

ADMINISTRATIF
"permis de construire", "PC", "DP",           → Administratif
"déclaration préalable", "PLU", "PLUi",
"urbanisme réglementaire", "ERP",
"autorisation travaux", "ABF", "patrimoine",
"marchés publics procédure", "CCTP", "DPGF",
"concours architecture"

CHANTIER
"coordination chantier", "DET",               → Chantier
"suivi travaux", "OPR", "levée réserves",
"coordo SPS", "sécurité chantier",
"planning chantier", "entreprises",
"sous-traitance", "réception travaux"

COMPTABILITÉ
"comptabilité agence", "fiscal",              → Comptabilité
"TVA", "micro-BNC", "BNC", "BIC",
"expert-comptable", "bilan", "trésorerie",
"transmission agence", "création agence"

PROSPECTION
"développement commercial", "clients",        → Prospection
"réseau", "candidatures marchés",
"réponse consultation", "acquisition",
"marketing agence", "notoriété"

RH
"recrutement", "emploi", "salaires",          → RH
"CCN architecture", "convention collective",
"IDCC 2332", "temps de travail",
"formation continue", "DPC", "CPF",
"management équipe"

SANTÉ MENTALE
"burn-out", "épuisement professionnel",       → Santé mentale
"souffrance au travail", "bien-être",
"harcèlement moral", "stress",
"soutien psychologique", "équilibre vie pro/perso"
──────────────────────────────────────────────────────────────────────────────

Logique de normalisation dans le worker

// Pseudo-code — pas du code final
function normalizeTag(raw) {
  const tag = raw.toLowerCase().trim()
  for (const [pattern, normalized] of TAG_MAP) {
    if (tag.includes(pattern)) return normalized
  }
  return null // tag non reconnu : ignoré, pas inséré
}

Si l'IA retourne un tag non reconnu, il est ignoré silencieusement (pas d'erreur). Si zéro tags valides après normalisation : champ laissé vide, fiche toujours traitée.


6. Circuit breaker budget 20€/mois

Logique de calcul des coûts

Mistral Nemo :
  cout = (tokens_in / 1_000_000 × 0.02) + (tokens_out / 1_000_000 × 0.04)
  (prix en USD — convertir en EUR au taux du jour ou fixer à 0.93)

Mistral Small :
  cout = (tokens_in / 1_000_000 × 0.20) + (tokens_out / 1_000_000 × 0.60)

Paliers de déclenchement

Seuil Action automatique
15€ Email Jules : "Budget IA : 15€ atteints ce mois. Estimation fin de mois : X€."
18€ Banner site : "La curation IA approche sa limite mensuelle. Soutenir NAV → [lien dons]"
19€ Email Jules : "Budget IA : 19€. Hard stop imminent."
20€ Hard stop : worker refuse tout nouvel appel IA. Banner site : "IA en pause ce mois. Reprise le 1er [mois+1]."

Implémentation

Avant chaque appel IA dans le worker :

1. SELECT SUM(cout_eur) FROM stats_usage
   WHERE timestamp >= DATE_TRUNC('month', NOW())

2. Si total >= 20.00 → throw new Error('BUDGET_EXCEEDED'), skip la fiche, continuer le cron

3. Si total >= 18.00 → set flag budget_warning=true en config (env ou NocoDB config row)
   → front affiche banner "pause imminente"

4. Emails automatiques à 15€ et 19€ :
   → Vérifier si email déjà envoyé ce mois (flag en NocoDB config ou fichier lock)
   → Si non : sendmail Jules, set flag

Gestion mid-pipeline

Si le budget est atteint pendant le traitement d'un batch :

  • La fiche en cours de traitement termine son cycle complet (pas d'état incohérent)
  • Les fiches suivantes dans le batch sont laissées en pending sans appel IA
  • Le cron repassera au prochain cycle, vérifiera le budget, et restera bloqué jusqu'au 1er du mois suivant

Reset mensuel

Cron mensuel (1er du mois à 00h05) :

  • Clear les flags budget (budget_15_sent, budget_19_sent, hard_stop_active)
  • Worker reprend normalement

7. Endpoints API à créer

Méthode  Endpoint              Description
──────────────────────────────────────────────────────────────────────────────
POST     /api/submit           Soumission nouvelle fiche
                               Body: { nom, url?, description_user?,
                                       echelle?, territoire?, tags_fonction?,
                                       localisation_ville?, submitted_by_email? }
                               Rate limit: 3 req/IP/jour
                               Réponse: 201 { message, id } | 400 (validation) |
                                        429 (rate limit)

POST     /api/comment          Soumission commentaire
                               Body: { orga_id, contenu, auteur_pseudo?,
                                       auteur_email? }
                               Rate limit: 5 req/IP/jour
                               Filtre éthique IA synchrone (Mistral Nemo)
                               Réponse: 201 { published, message } | 400 | 429

GET      /api/orgs             Liste des fiches publiées pour la carte
                               Query params: ?echelle=&territoire=&fonction=
                               Réponse: 200 [{ id, nom, url, description_enrichie,
                                              points_cles, echelle, territoire,
                                              tags_fonction, localisation_ville,
                                              latitude, longitude }]
                               Pas d'auth, public, cacheable 5 min

GET      /api/search           Recherche texte classique (fallback chatbot)
                               Query: ?q=string&limit=10
                               Recherche sur: nom, description_enrichie, tags_fonction
                               SQL ILIKE ou NocoDB search API
                               Réponse: 200 [{ id, nom, url, echelle, tags_fonction }]

POST     /api/chatbot          Requête chatbot Mistral Small
                               Body: { message, context_orga_ids?: [] }
                               Rate limit: 10 req/IP/jour
                               Mistral Small (stream optionnel MVP)
                               Réponse: 200 { answer, sources: [{id, nom}] }
                               Budget check avant appel

GET      /api/stats            Statistiques pour bandeau bas du site
                               Réponse: 200 { total_orgas, total_avis,
                                              budget_mois_eur, budget_max_eur,
                                              budget_warning: bool }
                               Cacheable 1 min
──────────────────────────────────────────────────────────────────────────────

Note sur /api/chatbot

Le chatbot (Mistral Small) reçoit les fiches publiées comme contexte RAG simplifié :

  • Fetch les 50 fiches les plus récentes (ou résultat de recherche)
  • Passe leur nom + description_enrichie + tags dans le prompt system
  • Mistral Small répond en mode assistant "je connais cet écosystème"
  • Pas de vector search en MVP — recherche par mots-clés suffit à ce stade

8. Rate limiting / anti-abus

Implémentation recommandée : fichier JSON local + cron reset

Redis serait plus robuste mais ajoute une dépendance. Pour le MVP, un fichier JSON par IP est suffisant.

Structure : /tmp/nav-ratelimit/{IP_hash}.json
{
  "submit": { "count": 2, "date": "2026-04-14" },
  "comment": { "count": 1, "date": "2026-04-14" },
  "chatbot": { "count": 5, "date": "2026-04-14" }
}

Middleware Node.js rateLimit(endpoint, maxPerDay) :

  1. Lire le fichier de l'IP (créer si absent)
  2. Si date != aujourd'hui → reset tous les compteurs
  3. Si count[endpoint] >= max → retourner 429
  4. Sinon : incrémenter et sauvegarder

Cron journalier (00h01) : purger les fichiers de rate limit > 2 jours.

Plafonds

Endpoint Max / IP / jour Justification
/api/submit 3 Anti-spam soumission fiches
/api/comment 5 Anti-spam commentaires
/api/chatbot 10 Budget IA + anti-abus
/api/search 100 Pas d'IA, peu de risque
/api/orgs illimité Public, cacheable

Gestion IP derrière proxy

Si VPS derrière reverse proxy (Caddy, Nginx) : utiliser le header X-Forwarded-For pour l'IP réelle. S'assurer que le proxy est configuré pour le passer (trusted_proxies dans Caddy).


9. Worker — implémentation

Choix : Option A — cron toutes les 5 min

Recommandation : cron 5 min.

Pourquoi pas le webhook NocoDB :

  • NocoDB webhooks (v0.9x) sont peu fiables sur les installs self-hosted — déclenchements manqués documentés
  • Le cron est plus simple à debugger (logs clairs, relance manuelle possible)
  • 5 min de latence est acceptable pour un process qui sera de toute façon modéré manuellement

Mise en place : crontab -e sur le VPS, ou PM2 avec cron: "*/5 * * * *" si Node.

Structure du script worker.js

Fichier : /opt/nav-worker/worker.js

IMPORTS :
  - node-fetch ou axios (appels HTTP)
  - crawl4ai (CLI local ou appel HTTP si installé comme service)
  - nodemailer (email Jules)
  - Zod (validation JSON Mistral)

CONSTANTES (depuis .env) :
  NOCODB_URL, NOCODB_TOKEN, NOCODB_BASE_ID, NOCODB_TABLE_ORGAS
  MISTRAL_API_KEY
  EMAIL_JULES, SMTP_*
  BUDGET_MAX_EUR (default: 20)
  WORKER_LOCK_FILE (default: /tmp/nav-worker.lock)

FLOW PRINCIPAL :

async function run() {
  // 1. Lock anti-overlap
  if (lockExists()) { log("Worker déjà en cours"); return }
  createLock()

  try {
    // 2. Check budget global
    const cumulMois = await getBudgetMoisCourant()
    if (cumulMois >= BUDGET_MAX_EUR) {
      log("Budget atteint, worker en pause")
      return
    }

    // 3. Fetch rows pending
    const rows = await fetchPendingRows()
    if (rows.length === 0) return

    let processedCount = 0

    for (const row of rows) {
      // Re-check budget avant chaque fiche
      const budget = await getBudgetMoisCourant()
      if (budget >= BUDGET_MAX_EUR) break

      // 4. Scrape si URL présente
      let scrapeContent = null
      if (row.url && row.scrape_status === 'pending') {
        try {
          scrapeContent = await scrapeWithCrawl4ai(row.url, timeout=180000)
          await updateRow(row.id, { scrape_status: 'scraped', scrape_content: scrapeContent })
        } catch (e) {
          await updateRow(row.id, { scrape_status: 'failed' })
          // On continue quand même avec IA (sans contenu scrape)
        }
      }

      // 5. Appel Mistral Nemo enrichissement
      const enriched = await callMistralWithRetry(
        buildEnrichmentPrompt(row, scrapeContent),
        model='open-mistral-nemo',
        maxRetries=2
      )

      if (!enriched) {
        await updateRow(row.id, { moderation_status: 'ai_error', ai_processed: true })
        continue
      }

      // 6. Normalisation tags
      enriched.tags_fonction = enriched.tags_fonction
        .map(normalizeTag)
        .filter(Boolean)

      // 7. Update NocoDB
      await updateRow(row.id, {
        description_enrichie: enriched.description_enrichie,
        points_cles: JSON.stringify(enriched.points_cles),
        tags_fonction: enriched.tags_fonction,
        echelle: enriched.echelle,
        territoire: enriched.territoire,
        localisation_ville: enriched.localisation_ville,
        moderation_status: 'ai_processed',
        ai_processed: true,
        ai_raw_output: JSON.stringify(enriched)
      })

      // 8. Log usage tokens
      await logUsage(enriched._usage, 'open-mistral-nemo', 'enrichissement', row.id)

      processedCount++
    }

    // 9. Email Jules si nouvelles fiches prêtes
    if (processedCount > 0) {
      await emailJules(processedCount)
    }

    // 10. Alerts budget
    await checkBudgetAlerts()

  } finally {
    removeLock()
  }
}

run().catch(err => {
  log("Worker error:", err)
  removeLock()
})

Détail crawl4ai

crawl4ai s'installe via pip et expose une CLI ou un serveur HTTP local. Appel recommandé : serveur HTTP local sur le VPS (démarré via Docker ou systemd).

# Installation
pip install crawl4ai
crawl4ai-setup

# Démarrer comme service (optionnel)
crawl4ai serve --port 11235

Depuis Node :

const res = await fetch('http://localhost:11235/crawl', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ url: row.url, max_pages: 1 }),
  signal: AbortSignal.timeout(180000) // 3 min
})
const data = await res.json()
return data.markdown || null

Si crawl4ai n'est pas disponible comme service : appel CLI via execSync avec timeout — moins propre, acceptable en MVP.


10. Fallbacks et gestion d'erreurs

Matrice des cas d'erreur

Situation                          Action worker           Status final
──────────────────────────────────────────────────────────────────────────────────
URL absente                        Passer directement à IA  scrape_status=no_link
                                   avec description_user    moderation_status=ai_processed

Scrape timeout (>3 min)            flag scrape_failed       scrape_status=failed
                                   IA appelée avec          moderation_status=ai_processed
                                   description_user seule   (Jules voit le flag)

Scrape 403 / 404                   Idem timeout             scrape_status=failed

Scrape JS-heavy (SPA)              crawl4ai gère JavaScript — si quand même vide :
                                   traiter comme no_link    scrape_status=failed

IA JSON mal formé (1er try)        Retry 1                  —
IA JSON mal formé (2e try)         Retry 2                  —
IA JSON mal formé (3e try)         log erreur               moderation_status=ai_error
                                                            ai_processed=true

IA timeout réseau                  Retry 2x (backoff 2s)    ai_error si 3 échecs

URL invalide au submit             Rejet 400 HTTP           Fiche jamais créée
                                   avant INSERT NocoDB

Email invalide au submit           Accepter la fiche        submitted_by_email vide
                                   Email ignoré             Pas d'email post-modération

Budget atteint mid-pipeline        Fiche en cours termine   Fiches suivantes en pending
                                   Worker s'arrête          Reprend au 1er du mois

Budget atteint au check            Worker s'arrête          Toutes les fiches en pending
avant le batch
──────────────────────────────────────────────────────────────────────────────────

Fiches avec scrape_failed : review Jules

Jules voit dans NocoDB UI les fiches avec scrape_status=failed et moderation_status=ai_processed. Il peut :

  • Approuver en l'état (description_user suffit)
  • Ajouter une description manuelle dans description_enrichie avant d'approuver
  • Rejeter si la fiche est trop vague

11. Sécurité

Gestion des secrets

Tous les secrets dans .env sur le VPS. Jamais dans le code source ni dans git.

# /opt/nav-worker/.env (chmod 600, owned root:root)
NOCODB_URL=http://localhost:8070
NOCODB_TOKEN=e9rUEwfUrE7mo_am0QAytwM0vCbwh4o0sisZIbHl
NOCODB_BASE_ID=pipilvsi7dibo80
NOCODB_TABLE_ORGAS=m08t7g5v4wch6wb
NOCODB_TABLE_AVIS=m4hub7cdutgec47
MISTRAL_API_KEY=sk-...
EMAIL_JULES=jules@...
SMTP_HOST=...
SMTP_PORT=587
SMTP_USER=...
SMTP_PASS=...
BUDGET_MAX_EUR=20

Ajouter .env au .gitignore impérativement.

Validation des inputs (Zod)

// Schéma de validation POST /api/submit
const submitSchema = z.object({
  nom: z.string().min(2).max(200),
  url: z.string().url().optional().or(z.literal('')),
  description_user: z.string().max(2000).optional(),
  echelle: z.enum(['National','Régional','Départemental','Local']).optional(),
  territoire: z.enum(['Métropole','Guadeloupe','Martinique','Guyane','Réunion','Mayotte']).optional(),
  tags_fonction: z.array(z.enum([
    'Juridique','Technique','Économique','Administratif',
    'Chantier','Comptabilité','Prospection','RH','Santé mentale'
  ])).max(5).optional(),
  localisation_ville: z.string().max(100).optional(),
  submitted_by_email: z.string().email().optional().or(z.literal(''))
})

Autres règles de sécurité

  • Pas d'eval/exec sur le contenu scrapé — le markdown retourné par crawl4ai est passé comme string dans le prompt, jamais exécuté
  • Sanitisation du contenu scrapé avant insertion en base : strip des balises HTML résiduelles (marked ou DOMPurify côté Node si nécessaire)
  • CSP headers sur toutes les routes API :
    Content-Security-Policy: default-src 'none'
    X-Content-Type-Options: nosniff
    X-Frame-Options: DENY
    
  • NocoDB exposé uniquement en localhost (pas de port 8070 ouvert sur le pare-feu Hetzner)
  • Worker tourne avec un user non-root sur le VPS (user dédié nav-worker)
  • Pas de logs des tokens Mistral en clair dans les fichiers log système (les ai_raw_output vont en NocoDB, pas dans syslog)

Notes pour l'agent dev

Ce qui est tranché dans cette spec

  • Stack : Node.js + NocoDB API + Mistral API + crawl4ai
  • Cron 5 min (pas webhook)
  • Deux modèles Mistral : Nemo pour worker + filtre, Small pour chatbot
  • Rate limiting fichier JSON (pas Redis)
  • Lock file anti-overlap pour le worker

Ce qui n'est pas dans cette spec (hors scope MVP)

  • Interface admin custom (Jules utilise NocoDB UI directement pour la modération)
  • Géocodage automatique (latitude/longitude) — à ajouter en V2.1
  • Vector search / RAG avancé pour le chatbot — SQLite FTS ou Postgres pgvector en V2.1
  • Streaming Mistral Small pour le chatbot — MVP retourne la réponse complète
  • Export CSV ou API publique fiches — à ajouter selon demande communauté
  • Tests automatisés — à ajouter avant toute mise en prod

Questions ouvertes pour Jules avant de coder

  1. Formulaire de soumission : outil existant (Tally, Formbricks) ou formulaire custom HTML ? Si Tally → l'endpoint /api/submit doit accepter le webhook Tally (format différent du body JSON standard)
  2. Email Jules : serveur SMTP existant sur le VPS, ou service transactionnel externe (Brevo, Mailgun) ? Si externe → ajouter la clé API dans .env
  3. Environnement de déploiement : le worker tourne-t-il dans Docker (Compose avec NocoDB) ou directement sur l'hôte (PM2 + cron) ?
  4. Version NocoDB : v0.9x ou v1.x ? L'API de patch des records diffère légèrement entre les deux versions
  5. Domaine API : même domaine que le front (reverse proxy Caddy) ou sous-domaine séparé (api.nav.trans-former.fr) ?

---

*Spec générée par ATIS le 2026-04-14. Alimenter un agent dev pour l'implémentation.*