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.
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 :
- Façade métier (
resolveAi,generateArticleAiBundle,generatePages) — point d'entrée des autres services. - Routeur provider (
callProvider) — choisit Anthropic ou OpenAI, traduit le contrat d'appel, capture la latence et les tokens. - Persistance & coût (
aiUsageInsert, helpers de pricing) — écrit dansai_usageet 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é | Type | Description |
|---|---|---|
global.ai.provider | string | anthropic (défaut) ou openai. |
global.ai.model | string | Modèle actif (ex. claude-sonnet-4, gpt-4o-mini). |
global.ai.api_key | string (secret) | Clé API du provider. Jamais renvoyée au frontend ni aux extensions. |
global.ai.max_tokens | integer | Plafond de sortie. Pour Anthropic, fixé à 8192 pour éviter la troncature des articles longs. |
global.ai.pricing.openai.input_per_1k | number | Prix par 1k tokens en entrée (OpenAI). |
global.ai.pricing.openai.output_per_1k | number | Prix par 1k tokens en sortie (OpenAI). |
global.ai.pricing.anthropic.input_per_1k | number | Prix par 1k tokens en entrée (Anthropic). Seed par défaut : 0.003. |
global.ai.pricing.anthropic.output_per_1k | number | Prix par 1k tokens en sortie (Anthropic). Seed par défaut : 0.015. |
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_usage | Source |
|---|---|
provider | global.ai.provider au moment de l'appel |
model | global.ai.model ou override opts.model |
input_tokens | Réponse provider (usage.input_tokens Anthropic, usage.prompt_tokens OpenAI) |
output_tokens | Réponse provider (usage.output_tokens / usage.completion_tokens) |
duration_ms | Mesure interne (performance.now()) |
success | 1 si la réponse parse correctement, 0 sinon |
error_code | HTTP status ou mot-clé (RATE_LIMIT, TIMEOUT, PARSE_ERROR) |
kind | Champ métier (cocon, page, article, blog, other) |
action | Libellé libre (ex. cocon_html_generated, article_intro_resolved) |
resource_id | Id 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.
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 :
article.intro— chapeau introductif de l'article (~80 mots).article.h2.1,article.h2.2— titres de sections principales.article.p.1.1— premier paragraphe de la première section.blog.faq.q1/blog.faq.a1— paires question/réponse FAQ.blog.cta.title— titre du CTA final.cocon.parent.intro,cocon.child.body— variantes spécifiques cocon.page.hero.title,page.section.body— pour les pages classiques.
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. |
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 :
- HTML par clé — un objet
{ "article.intro": "...", "article.h2.1": "..." }qui sera injecté dans le template. - JSON-LD Article — bloc complet
schema.org/Articleavecheadline,author,datePublished,image. - SEO meta — title (≤60 car.), meta description (≤155 car.), URL canonique, Open Graph.
- Image queries — liste de requêtes pour la featured image et les images de section, transmises ensuite aux providers d'images (Unsplash, Pexels, Pixabay).
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.
| Fonction | Cas d'usage | Appels IA | Cohérence narrative |
|---|---|---|---|
generateArticleAiBundle | Nouveaux articles data-ai | 1 (bundle) | Forte |
generateArticleHtml | Articles legacy data-cms | N (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 :
- Calcule le produit cartésien des variables pour obtenir N combinaisons (ici 2 × 3 = 6).
- Filtre les combinaisons déjà générées (lookup en base sur
generated_pages). - Vérifie le quota anti-spam du groupe (5 pages / 7 jours par défaut, configurable via
PUT /cocon/groups/:id/quota). - 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.
- Pour chaque combinaison restante, lance en parallèle un
resolveAi()aveckind = "cocon"et un prompt construit depuis le template et les variables. - Persiste les pages générées et déclenche la notification email aux rôles
adminetwebmaster5 minutes après chaque créneau libéré.
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 :
| Provider | Clé attendue | Notes |
|---|---|---|
| Unsplash | global.images.unsplash.access_key | Provider par défaut. Quota gratuit 50 req/h. |
| Pexels | global.images.pexels.api_key | Fallback recommandé. Quota gratuit 200 req/h. |
| Pixabay | global.images.pixabay.api_key | Bascule 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 :
| Endpoint | Méthode | Limite |
|---|---|---|
/api/blog/articles/:id/generate | POST | 10 / min |
/api/blog/articles/:id/regenerate-image | POST | 10 / min |
/api/cocon/groups/:id/generate | POST | 10 / min |
/api/ai/usage/export.csv | GET | 10 / 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é.
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éthode | Route | Description |
|---|---|---|
| GET | /api/ai/status | État des providers (clés présentes ou non), sans rate limit. |
| GET | /api/ai/config | Configuration courante, clé API masquée. |
| PUT | /api/ai/config | Modifie provider, modèle, clé, pricing, max_tokens. |
| GET | /api/ai/usage | Stats agrégées (summary + by-day + by-kind). |
| GET | /api/ai/usage/export.csv | Export 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 }'