Moteur IA

Le moteur IA du Labo du Yeti centralise tous les appels aux modèles de langage et aux banques d'images derrière une seule façade : ai-service. L'objectif est triple : (1) abstraire le provider (Anthropic Claude ou OpenAI) pour rendre le code métier indépendant du fournisseur, (2) tracer chaque appel dans la table ai_usage pour le suivi de consommation et de coût, et (3) résoudre la génération de contenu par clé sémantique plutôt que par prompt libre, afin que les templates HTML restent purement déclaratifs.

Par défaut : Anthropic Claude (Sonnet) avec un quota max_tokens = 8192. OpenAI reste disponible en bascule via la configuration. La clé API n'est jamais exposée aux extensions ni au frontend.

Architecture générale

Tout le code IA vit dans backend/src/services/ai-service.js, exposé via le router backend/src/routes/ai.js (préfixe /api/ai). Le service est consommé par blog-service, cocon-service et pages-service chaque fois qu'un contenu doit être généré, réécrit ou enrichi. L'ensemble respecte trois couches strictes :

  1. Façade métier (resolveAi, generateArticleAiBundle, generatePages) — point d'entrée des autres services.
  2. Routeur provider (callProvider) — choisit Anthropic ou OpenAI, traduit le contrat d'appel, capture la latence et les tokens.
  3. Persistance & coût (aiUsageInsert, helpers de pricing) — écrit dans ai_usage et calcule le coût estimé.

Configuration globale

La configuration IA vit dans le store global.ai (voir Base de données). Elle expose les champs suivants :

CléTypeDescription
global.ai.providerstringanthropic (défaut) ou openai.
global.ai.modelstringModèle actif (ex. claude-sonnet-4, gpt-4o-mini).
global.ai.api_keystring (secret)Clé API du provider. Jamais renvoyée au frontend ni aux extensions.
global.ai.max_tokensintegerPlafond de sortie. Pour Anthropic, fixé à 8192 pour éviter la troncature des articles longs.
global.ai.pricing.openai.input_per_1knumberPrix par 1k tokens en entrée (OpenAI).
global.ai.pricing.openai.output_per_1knumberPrix par 1k tokens en sortie (OpenAI).
global.ai.pricing.anthropic.input_per_1knumberPrix par 1k tokens en entrée (Anthropic). Seed par défaut : 0.003.
global.ai.pricing.anthropic.output_per_1knumberPrix par 1k tokens en sortie (Anthropic). Seed par défaut : 0.015.
Sécurité. Le GET /api/ai/config renvoie la configuration masquée (api_key remplacée par un placeholder). Seuls les rôles owner et admin avec la capability settings_ai peuvent lire ou écrire ce store.

resolveAi(prompt, opts) — façade unifiée

resolveAi(prompt, opts) est le point d'entrée unique pour générer du texte. Son rôle est de masquer entièrement le provider sous-jacent et de garantir qu'aucun appel IA ne s'évade du tracing standard. La signature est volontairement minimale :

async function resolveAi(prompt, opts = {}) {
  // opts.kind         : "cocon" | "page" | "article" | "blog" | "other"
  // opts.action       : libellé fonctionnel libre (ex. "resolve_inline")
  // opts.resource_id  : id de la ressource cible (article, page, group)
  // opts.json         : true => parse la réponse en JSON strict
  // opts.max_tokens   : override du max_tokens global
  // opts.system       : prompt système optionnel
  const cfg = await readStore('global');
  const provider = cfg?.ai?.provider || 'anthropic';
  const apiKey   = cfg?.ai?.api_key;
  if (!apiKey) throw new Error('AI_NO_KEY');
  return callProvider({ provider, prompt, apiKey, model: cfg.ai.model, ...opts });
}

Tout appelant — service blog, cocon, page ou extension — passe par cette fonction. Les options sont volontairement faibles : aucun champ de routing exotique ne fuite dans le code métier, ce qui permet de changer de provider sans modifier les services consommateurs.

callProvider() — routeur + tracing

La fonction interne callProvider() traduit l'appel vers Anthropic Messages API ou OpenAI Chat Completions API, mesure la latence avec performance.now(), capture les tokens d'usage retournés par le provider, et insère systématiquement une ligne dans la table ai_usage. Aucun chemin n'est exempté : un succès comme un échec produisent une entrée (avec success = 0 et error_code renseigné en cas d'erreur).

Champ ai_usageSource
providerglobal.ai.provider au moment de l'appel
modelglobal.ai.model ou override opts.model
input_tokensRéponse provider (usage.input_tokens Anthropic, usage.prompt_tokens OpenAI)
output_tokensRéponse provider (usage.output_tokens / usage.completion_tokens)
duration_msMesure interne (performance.now())
success1 si la réponse parse correctement, 0 sinon
error_codeHTTP status ou mot-clé (RATE_LIMIT, TIMEOUT, PARSE_ERROR)
kindChamp métier (cocon, page, article, blog, other)
actionLibellé libre (ex. cocon_html_generated, article_intro_resolved)
resource_idId de la ressource cible (article, page, content_group)

Calcul du coût estimé

Le coût n'est pas stocké en base : il est recalculé à la lecture à partir des tokens et du barème courant. Cela permet d'ajuster les prix unitaires sans réécrire l'historique. La formule est triviale :

function estimateCost(row, pricing) {
  const p = pricing[row.provider] || { input_per_1k: 0, output_per_1k: 0 };
  const cIn  = (row.input_tokens  / 1000) * p.input_per_1k;
  const cOut = (row.output_tokens / 1000) * p.output_per_1k;
  return cIn + cOut;
}

Le dashboard /settings/ai consomme aiUsageSummary(), aiUsageByKind() et aiUsageByDay() pour afficher : volume d'appels, taux d'échec, tokens cumulés et coût agrégé par jour, par kind et global. L'export CSV (GET /api/ai/usage/export.csv) reprend la même formule, ligne par ligne.

À retenir : seuls les tokens sont des faits persistés. Le prix est toujours dérivé du barème courant de global.ai.pricing, ce qui permet de corriger un tarif sans toucher à ai_usage.

describeArticleKey() — prompts par clé sémantique

Les templates HTML d'articles, de pages et de cocon n'embarquent aucun prompt. Chaque élément à générer porte un attribut data-ai="article.intro", data-ai="blog.faq.q1" ou similaire. La fonction describeArticleKey(key, ctx) mappe cette clé sémantique vers un prompt structuré côté serveur. Le mapping centralisé garantit la cohérence du ton, le respect du brief SEO et l'application des contraintes (longueur, structure, registre de langue).

Grammaire des clés

Les clés suivent une convention par segments séparés par des points :

describeArticleKey() joint le contexte courant (titre cible, mots-clés, ville/service pour le cocon, tone-of-voice du site) au prompt sémantique, puis délègue à resolveAi() avec kind dérivé de la racine de la clé.

SKIP_AI_KEYS — éléments structurels exclus

Certains blocs HTML portent un attribut data-ai uniquement pour le repérage visuel ou la composition, mais ne doivent jamais déclencher un appel IA. La liste blanche SKIP_AI_KEYS les écarte du résolveur :

Préfixe de cléRaison du skip
form.*Champs de formulaire — gérés par data-cms-form / data-cms-field.
quiz.*Quiz multi-étapes statiques ou pilotés par config éditoriale.
related.*Articles liés calculés par blog-service.
sources.*Sources fournies par l'auteur ou la recherche web.
author.*Métadonnées auteur, jamais générées.
breadcrumb.*Fil d'Ariane calculé à partir de la hiérarchie du site.
Garde-fou. Si un template laisse échapper une clé form.x par erreur, le résolveur la voit, vérifie le préfixe et passe son tour sans appeler le provider — pas de fuite de tokens et pas de pollution du HTML final.

generateArticleAiBundle — articles blog complets

Pour le blog, la génération article par article repose sur generateArticleAiBundle(article, opts), défini dans backend/src/services/blog-service.js et orchestré via ai-service. Plutôt que de boucler sur chaque data-ai du template (coûteux en appels), cette fonction émet un seul prompt structuré qui retourne un bundle JSON contenant l'intégralité du contenu nécessaire :

Le retour est ensuite découpé et injecté dans le DOM du template via Cheerio. Cette approche minimise drastiquement le nombre d'appels (1 au lieu de N) et garantit la cohérence narrative de l'article — toutes les sections sont produites dans le même contexte de génération.

generateArticleHtml — chemin legacy data-cms

Pour les anciens articles construits avec data-cms (avant l'introduction de data-ai), le service conserve generateArticleHtml(article). Cette fonction parcourt le DOM, repère les marqueurs data-cms, et résout chaque bloc avec resolveAi() en série. Elle reste utile pour les templates historiques et pour les blocs ponctuels mis à jour à la demande, mais elle n'est plus recommandée pour les nouveaux articles.

FonctionCas d'usageAppels IACohérence narrative
generateArticleAiBundleNouveaux articles data-ai1 (bundle)Forte
generateArticleHtmlArticles legacy data-cmsN (un par bloc)Faible

Cocon SEO — generatePages

Le cocon sémantique applique une logique différente : un content group définit un template HTML et un ensemble de variables cartésiennes (par exemple { service: ['plomberie', 'électricité'], ville: ['Liège', 'Namur', 'Bruxelles'] }). La fonction generatePages(groupId, opts) de cocon-service :

  1. Calcule le produit cartésien des variables pour obtenir N combinaisons (ici 2 × 3 = 6).
  2. Filtre les combinaisons déjà générées (lookup en base sur generated_pages).
  3. Vérifie le quota anti-spam du groupe (5 pages / 7 jours par défaut, configurable via PUT /cocon/groups/:id/quota).
  4. Vérifie la règle parent → enfant : aucune page enfant n'est générable tant que tous les parents du groupe ne sont pas générés ET buildés.
  5. Pour chaque combinaison restante, lance en parallèle un resolveAi() avec kind = "cocon" et un prompt construit depuis le template et les variables.
  6. Persiste les pages générées et déclenche la notification email aux rôles admin et webmaster 5 minutes après chaque créneau libéré.
Suivi en temps réel. Pendant la génération, le frontend interroge GET /cocon/groups/:id/generation-progress pour afficher la barre de progression et GET /cocon/groups/:id/quota-status pour le compteur quota restant.

Providers d'images

La génération de texte n'est qu'une moitié du moteur. Pour chaque article ou page, des images doivent être sélectionnées et liées au contenu. La configuration des banques d'images vit dans global.images et est gérée via /api/images. Trois providers sont supportés en bascule transparente :

ProviderClé attendueNotes
Unsplashglobal.images.unsplash.access_keyProvider par défaut. Quota gratuit 50 req/h.
Pexelsglobal.images.pexels.api_keyFallback recommandé. Quota gratuit 200 req/h.
Pixabayglobal.images.pixabay.api_keyBascule complémentaire.

Le pipeline IA appelle POST /api/images/search avec les image queries issues du bundle, télécharge les meilleures correspondances, les normalise via sharp (compression, WebP éventuel via l'extension webp-auto-converter) et les attache à l'article ou à la page comme featured image. Le bouton POST /api/blog/articles/:id/regenerate-image permet de rejouer ce sous-pipeline sans relancer la génération du texte.

Rate limiting & garde-fous

Les endpoints qui déclenchent une génération IA sont protégés par le middleware aiGenLimiter qui plafonne à 10 requêtes par minute par utilisateur. La logique est identique côté blog et côté cocon :

EndpointMéthodeLimite
/api/blog/articles/:id/generatePOST10 / min
/api/blog/articles/:id/regenerate-imagePOST10 / min
/api/cocon/groups/:id/generatePOST10 / min
/api/ai/usage/export.csvGET10 / min

Le rate limit s'applique avant l'auth pour économiser la vérification JWT lors d'un flood, mais utilise l'identifiant utilisateur en clé lorsque l'auth réussit. En cas de dépassement, la réponse est 429 Too Many Requests.

max_tokens Anthropic = 8192

Anthropic impose une borne explicite sur la longueur de sortie via le paramètre max_tokens. Le défaut Anthropic est faible (1024) et provoque des troncatures silencieuses sur les articles longs ou les bundles JSON volumineux. Le moteur force donc max_tokens = 8192 pour tous les appels Claude Sonnet, ce qui couvre confortablement un article complet de 2500 mots avec son JSON-LD et ses meta SEO. Si un bundle dépasse cette borne, le parser JSON détecte l'objet incomplet, marque l'appel success = 0 avec error_code = "TRUNCATED" et la génération est rejouée en mode segmenté.

Pourquoi 8192 et pas plus ? Au-delà, le coût et la latence explosent. 8192 tokens couvrent ~6000 mots en français, ce qui dépasse de loin la longueur des articles cibles (1200-2500 mots).

Endpoints d'administration

L'administration de la configuration IA et la consultation des statistiques se font via /api/ai. Tous les endpoints (sauf /status) exigent requireAuth + requireRole(owner|admin) + requireCapability(settings_ai).

MéthodeRouteDescription
GET/api/ai/statusÉtat des providers (clés présentes ou non), sans rate limit.
GET/api/ai/configConfiguration courante, clé API masquée.
PUT/api/ai/configModifie provider, modèle, clé, pricing, max_tokens.
GET/api/ai/usageStats agrégées (summary + by-day + by-kind).
GET/api/ai/usage/export.csvExport CSV ligne par ligne, calcul coût inclus.

Exemples d'usage

Résoudre une clé sémantique

import { resolveAi } from '../services/ai-service.js';

const html = await resolveAi(
  describeArticleKey('article.intro', { title: article.title, kw: article.keywords }),
  { kind: 'article', action: 'resolve_inline', resource_id: article.id }
);

Générer un article complet

import { generateArticleAiBundle } from '../services/blog-service.js';

const bundle = await generateArticleAiBundle(article);
// bundle = { html: { 'article.intro': '...', ... }, jsonLd: {...}, seo: {...}, imageQueries: [...] }
await applyBundleToTemplate(article.id, bundle);

Lancer une génération cocon

curl -X POST https://example.com/api/cocon/groups/abc/generate \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "limit": 3 }'

Liens utiles