Aller au contenu principal

Implémentation backend — AI Gateway (état mai 2026)

Statut

Document d'état réel — décrit le code déployé en production.
Mis à jour le 2026-05-23. À faire valider avant toute extension.


Vue d'ensemble

Le module IA est entièrement côté backend Laravel (api/).
Aucun appel provider ne part des frontends. Le schéma général est le suivant :

Frontend (dmv-public ou backoffice)
→ POST /api/v1/ai/... (Sanctum Bearer + X-App-Source: dmv)
→ AIController
→ AIGatewayService
├── AIQuotaService (vérification avant, consommation après)
├── AiPrompt (DB) (sélection du prompt actif)
├── PromptRenderer (injection variables {{nom}})
├── AICacheService (lecture/écriture cache DB)
├── AIProviderManager (routing vers le bon provider)
│ └── OpenAIProvider (seul provider câblé)
└── AIUsageLogger (log tokens, coût, statut)

Structure du module

Chemin : api/app/Modules/AI/

Sous-dossierContenu
Controllers/AIController — 5 endpoints publics + 1 quota
Services/Gateway, Quota, Cache, Logger, PromptRenderer, ProviderManager, QuotaSync
Providers/AIModuleServiceProvider, OpenAIProvider
Models/AiPrompt, AiUserQuota, AiCache, AiUsageLog
DTOs/AIResponseDTO, AIProviderResponse
Requests/Validation FormRequest pour chaque endpoint
Exceptions/AIQuotaExceededException, AIProviderException, AIPromptNotFoundException
Contracts/Interface AIProviderInterface
Jobs/PruneAiCache (job de purge)
Routes/api.php — toutes les routes IA

Endpoints exposés

Base : /api/v1/ — middleware identify.app + auth:sanctum

MéthodeRouteActionPrompt
GET/ai/quotaRetourne le quota courant de l'acteur
POST/ai/publication/improveAméliore un texte avec ton choisipublication.improve
POST/ai/publication/generateGénère titre + texte depuis une idée brutepublication.generate
POST/ai/publication/variantsProduit 4 variantes multi-canalpublication.variants
POST/ai/publication/tagsSuggère tags et catégoriespublication.tags
POST/ai/onboarding/suggestGénère profil complet lors de la création acteuronboarding.suggest

Format de réponse unifié :

{
"success": true,
"data": { ...résultat spécifique au prompt... },
"meta": {
"cached": false,
"credits_used": 1,
"model": "gpt-4o-mini"
}
}

Quota (GET /ai/quota) :

{
"monthly_limit": 50,
"monthly_used": 12,
"bonus_credits": 5,
"available": 43,
"reset_at": "2026-06-01T00:00:00+00:00"
}

Paramètre optionnel : ?actor_id=<uuid> pour cibler un acteur précis.


AIGatewayService — flux d'exécution

Fichier : Services/AIGatewayService.php

  1. Vérification du quota (AIQuotaService::check). Lance AIQuotaExceededException si dépassé.
  2. Récupération du prompt actif en base (ai_prompts WHERE key + is_active = true ORDER BY version DESC).
  3. Rendu du prompt via PromptRenderer (substitution {{variable}}).
  4. Calcul du hash SHA-256 de l'input → recherche dans ai_cache.
  5. Si cache valide → retour immédiat, credits_used = 0.
  6. Appel au provider actif (OpenAIProvider::complete).
  7. Décodage JSON de la réponse (toutes les réponses sont en response_format: json_object).
  8. Écriture en cache (AICacheService::put).
  9. Log de succès avec tokens et coût estimé (AIUsageLogger::logSuccess).
  10. Consommation d'un crédit (AIQuotaService::consume).

En cas d'erreur provider : log d'erreur + propagation de AIProviderException.


Provider : OpenAI

Fichier : Providers/OpenAIProvider.php

  • Appel POST /chat/completions via Laravel HTTP Client.
  • response_format: json_object forcé → toutes les réponses sont du JSON pur.
  • temperature: 0.7, max_tokens: 1500.
  • Timeout configurable via AI_REQUEST_TIMEOUT (défaut : 30 s).
  • Clé API : variable d'environnement OPENAI_API_KEY.
  • URL de base : OPENAI_BASE_URL (défaut : https://api.openai.com/v1).

Grille de coût estimé (USD pour 1M tokens, embarquée dans le code) :

ModèleInputOutput
gpt-4o-mini0,15 $0,60 $
gpt-4o2,50 $10,00 $
gpt-4-turbo10,00 $30,00 $
gpt-3.50,50 $1,50 $

Gestion des prompts

Table : ai_prompts

ColonneTypeDescription
iduuidPK, gen_random_uuid()
keyvarchar(100)Identifiant métier, ex: publication.improve
versionsmallintVersionnement (UNIQUE avec key)
namevarchar(150)Libellé humain
descriptiontextDescription optionnelle
system_promptlongtextInstruction système
user_prompt_templatelongtextTemplate avec variables {{nom}}
model_defaultvarchar(100)Surcharge du modèle (nullable)
is_activebooleanSeuls les actifs sont utilisés

5 prompts seedés (v1, tous actifs) :

CléUsageVariables
publication.improveAmélioration avec tontexte, ton, commune
publication.variants4 variantes multi-canaltexte, commune
publication.tagsTags + catégoriestexte, titre
publication.generateGénération depuis idée bruteidee, commune
onboarding.suggestProfil complet acteurnom, activite, commune, type

Le système prompt commun est : "Tu es l'assistant de communication locale de DMV. Tu réponds UNIQUEMENT en JSON valide."

Modification des prompts : via la table ai_prompts directement (pas encore d'interface admin backoffice pour les prompts).


Système de cache

Table : ai_cache

ColonneTypeDescription
cache_keyvarchar(64) UNIQUESHA-256(prompt_key + `
prompt_keyvarchar(100)Référence du prompt
input_hashvarchar(64)SHA-256 de l'input rendu
response_jsonlongtextRéponse JSON sérialisée
expires_attimestampExpiration (null = permanent)

TTL : configurable via AI_CACHE_TTL_SECONDS (défaut : 86400 s = 24 h).
Clé identique = même prompt + même input → réponse en cache retournée sans appel provider, crédit non consommé.
Purge : job PruneAiCache à brancher sur le scheduler Laravel (non encore planifié).


Logs d'utilisation

Table : ai_usage_logs

ColonneTypeDescription
user_iduuidUtilisateur (nullable)
actor_iduuidActeur concerné (nullable)
prompt_keyvarchar(100)Clé du prompt
prompt_versionsmallintVersion du prompt utilisée
providervarchar(50)Nom du provider (ex: openai)
modelvarchar(100)Modèle exact (ex: gpt-4o-mini)
actionvarchar(100)Label métier (ex: publication.improve)
input_hashvarchar(64)Hash de l'input (pas le contenu)
input_tokensuintTokens d'entrée
output_tokensuintTokens de sortie
total_tokensuintTotal
estimated_costdecimal(10,6)Coût USD estimé
statusvarchar(20)success ou error
error_messagetextMessage d'erreur (si error)

Index sur : user_id, actor_id, prompt_key, status, created_at.


Système de quotas IA

Table : ai_user_quotas (migrations 000055 + 000058)

ColonneTypeDescription
iduuidPK
user_iduuidUNIQUE — utilisateur lié
acteur_iduuidNullable — quota par acteur
monthly_limituintCrédits mensuels alloués
monthly_useduintCrédits consommés ce mois
bonus_creditsuintBonus proratés lors d'un upgrade
reset_attimestampDate de remise à zéro mensuelle

Logique availableCredits() (dans le modèle) :

disponibles = max(0, (monthly_limit - monthly_used) + bonus_credits)

Comportement getOrCreate :

  • Si acteur_id fourni → cherche par acteur_id.
  • Sinon → cherche par user_id WHERE acteur_id IS NULL.
  • Si aucun enregistrement → crée avec monthly_limit = AI_MONTHLY_FREE_LIMIT (défaut : 50).

Reset automatique : lors de chaque check(), si reset_at est passé → monthly_used remis à 0, reset_at décalé au 1er du mois suivant. Les bonus_credits ne sont pas remis à zéro.


Synchronisation quota ↔ plan d'abonnement

AISubscriptionQuotaSync

Fichier : Services/AISubscriptionQuotaSync.php

Déclenché lors d'un changement de plan (via AdminSubscriptionWriteServicePlanQuotaSync).

Upgrade / admin_grant :

bonus_proraté = ceil((nouveau_limit - ancien_limit) × jours_restants / jours_du_mois)

Le bonus est ajouté aux bonus_credits existants.

Downgrade :
Met à jour monthly_limit au nouveau plafond. Les bonus_credits acquis sont conservés.

Champ plan : subscription_plans.ai_credits_mensuel (integer, 0 = aucun, -1 = illimité, N = N crédits/mois).

PlanQuotaSync

Fichier : Admin/Services/PlanQuotaSync.php

Orchestrateur général pour tous les changements de plan. Délègue à AISubscriptionQuotaSync et gère en plus le prorata push.

Prorata push (upgrade / admin_grant uniquement) :

bonus_abonnés = ceil((nb_push_abonnes_new - nb_push_abonnes_old) × jours_restants / jours_du_mois)
bonus_commune = ceil((nb_push_commune_new - nb_push_commune_old) × jours_restants / jours_du_mois)

Écrit dans push_usage.push_abonnes_bonus et push_usage.push_commune_bonus pour le mois courant (format YYYY-MM).

Règle d'arrondi : toujours ceil() — en faveur du client.


Extensions de schéma existant

Migration 000057subscription_plans.ai_credits_mensuel :

INTEGER DEFAULT 0 -- 0=aucun, -1=illimité, N=N/mois

Migration 000058ai_user_quotas.acteur_id :

UUID NULLABLE -- index; permet un quota par acteur au lieu d'un quota par user

Migration 000059push_usage.push_abonnes_bonus et push_usage.push_commune_bonus :

INTEGER DEFAULT 0 -- crédits push bonus accordés lors d'un upgrade en cours de mois

Configuration .env requise

VariableDéfautDescription
OPENAI_API_KEY(vide)Clé API OpenAI — obligatoire pour activer l'IA
OPENAI_BASE_URLhttps://api.openai.com/v1URL de base (compatible proxies)
AI_PROVIDERopenaiProvider actif
AI_MODEL_DEFAULTgpt-4o-miniModèle par défaut
AI_MONTHLY_FREE_LIMIT50Quota mensuel gratuit
AI_CACHE_TTL_SECONDS86400TTL cache (0 = désactivé)
AI_REQUEST_TIMEOUT30Timeout HTTP provider (s)
AI_MAX_INPUT_LENGTH5000Taille max input (caractères)

État actuel en production : OPENAI_API_KEY non encore renseignée → l'IA ne répond pas. L'infrastructure est opérationnelle, il suffit d'ajouter la clé.


Ce qui n'est pas encore implémenté (côté backend)

FonctionnalitéÉtat
Interface admin pour éditer les promptsNon — modifications directes en base
Stats/dashboard IA dans le backofficeNon — les logs existent, pas de vue
Second provider (Anthropic, Mistral, local)Interface prête (AIProviderInterface), aucun câblé
Quota illimité (ai_credits_mensuel = -1)Non géré dans AIQuotaService.check()
Remise à zéro des bonus_credits au reset mensuelNon — ils s'accumulent
Job PruneAiCache planifiéCode présent, non branché au scheduler
Limite AI_MAX_INPUT_LENGTH appliquéeConfig présente, non vérifiée dans les Requests