feat(aep): carte AEP — push Gitea 2026-04-28
This commit is contained in:
824
V2-cadrage/F-spec-pipe-collaboration.md
Normal file
824
V2-cadrage/F-spec-pipe-collaboration.md
Normal file
@@ -0,0 +1,824 @@
|
||||
# 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
|
||||
|
||||
```javascript
|
||||
// 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).
|
||||
|
||||
```bash
|
||||
# Installation
|
||||
pip install crawl4ai
|
||||
crawl4ai-setup
|
||||
|
||||
# Démarrer comme service (optionnel)
|
||||
crawl4ai serve --port 11235
|
||||
```
|
||||
|
||||
Depuis Node :
|
||||
```javascript
|
||||
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.
|
||||
|
||||
```bash
|
||||
# /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)
|
||||
|
||||
```javascript
|
||||
// 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.*
|
||||
Reference in New Issue
Block a user