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-dossier | Contenu |
|---|---|
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éthode | Route | Action | Prompt |
|---|---|---|---|
| GET | /ai/quota | Retourne le quota courant de l'acteur | — |
| POST | /ai/publication/improve | Améliore un texte avec ton choisi | publication.improve |
| POST | /ai/publication/generate | Génère titre + texte depuis une idée brute | publication.generate |
| POST | /ai/publication/variants | Produit 4 variantes multi-canal | publication.variants |
| POST | /ai/publication/tags | Suggère tags et catégories | publication.tags |
| POST | /ai/onboarding/suggest | Génère profil complet lors de la création acteur | onboarding.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
- Vérification du quota (
AIQuotaService::check). LanceAIQuotaExceededExceptionsi dépassé. - Récupération du prompt actif en base (
ai_promptsWHEREkey+is_active = trueORDER BYversion DESC). - Rendu du prompt via
PromptRenderer(substitution{{variable}}). - Calcul du hash SHA-256 de l'input → recherche dans
ai_cache. - Si cache valide → retour immédiat,
credits_used = 0. - Appel au provider actif (
OpenAIProvider::complete). - Décodage JSON de la réponse (toutes les réponses sont en
response_format: json_object). - Écriture en cache (
AICacheService::put). - Log de succès avec tokens et coût estimé (
AIUsageLogger::logSuccess). - 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/completionsvia Laravel HTTP Client. response_format: json_objectforcé → 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èle | Input | Output |
|---|---|---|
| gpt-4o-mini | 0,15 $ | 0,60 $ |
| gpt-4o | 2,50 $ | 10,00 $ |
| gpt-4-turbo | 10,00 $ | 30,00 $ |
| gpt-3.5 | 0,50 $ | 1,50 $ |
Gestion des prompts
Table : ai_prompts
| Colonne | Type | Description |
|---|---|---|
id | uuid | PK, gen_random_uuid() |
key | varchar(100) | Identifiant métier, ex: publication.improve |
version | smallint | Versionnement (UNIQUE avec key) |
name | varchar(150) | Libellé humain |
description | text | Description optionnelle |
system_prompt | longtext | Instruction système |
user_prompt_template | longtext | Template avec variables {{nom}} |
model_default | varchar(100) | Surcharge du modèle (nullable) |
is_active | boolean | Seuls les actifs sont utilisés |
5 prompts seedés (v1, tous actifs) :
| Clé | Usage | Variables |
|---|---|---|
publication.improve | Amélioration avec ton | texte, ton, commune |
publication.variants | 4 variantes multi-canal | texte, commune |
publication.tags | Tags + catégories | texte, titre |
publication.generate | Génération depuis idée brute | idee, commune |
onboarding.suggest | Profil complet acteur | nom, 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
| Colonne | Type | Description |
|---|---|---|
cache_key | varchar(64) UNIQUE | SHA-256(prompt_key + ` |
prompt_key | varchar(100) | Référence du prompt |
input_hash | varchar(64) | SHA-256 de l'input rendu |
response_json | longtext | Réponse JSON sérialisée |
expires_at | timestamp | Expiration (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
| Colonne | Type | Description |
|---|---|---|
user_id | uuid | Utilisateur (nullable) |
actor_id | uuid | Acteur concerné (nullable) |
prompt_key | varchar(100) | Clé du prompt |
prompt_version | smallint | Version du prompt utilisée |
provider | varchar(50) | Nom du provider (ex: openai) |
model | varchar(100) | Modèle exact (ex: gpt-4o-mini) |
action | varchar(100) | Label métier (ex: publication.improve) |
input_hash | varchar(64) | Hash de l'input (pas le contenu) |
input_tokens | uint | Tokens d'entrée |
output_tokens | uint | Tokens de sortie |
total_tokens | uint | Total |
estimated_cost | decimal(10,6) | Coût USD estimé |
status | varchar(20) | success ou error |
error_message | text | Message 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)
| Colonne | Type | Description |
|---|---|---|
id | uuid | PK |
user_id | uuid | UNIQUE — utilisateur lié |
acteur_id | uuid | Nullable — quota par acteur |
monthly_limit | uint | Crédits mensuels alloués |
monthly_used | uint | Crédits consommés ce mois |
bonus_credits | uint | Bonus proratés lors d'un upgrade |
reset_at | timestamp | Date de remise à zéro mensuelle |
Logique availableCredits() (dans le modèle) :
disponibles = max(0, (monthly_limit - monthly_used) + bonus_credits)
Comportement getOrCreate :
- Si
acteur_idfourni → cherche paracteur_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 AdminSubscriptionWriteService → PlanQuotaSync).
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 000057 — subscription_plans.ai_credits_mensuel :
INTEGER DEFAULT 0 -- 0=aucun, -1=illimité, N=N/mois
Migration 000058 — ai_user_quotas.acteur_id :
UUID NULLABLE -- index; permet un quota par acteur au lieu d'un quota par user
Migration 000059 — push_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
| Variable | Défaut | Description |
|---|---|---|
OPENAI_API_KEY | (vide) | Clé API OpenAI — obligatoire pour activer l'IA |
OPENAI_BASE_URL | https://api.openai.com/v1 | URL de base (compatible proxies) |
AI_PROVIDER | openai | Provider actif |
AI_MODEL_DEFAULT | gpt-4o-mini | Modèle par défaut |
AI_MONTHLY_FREE_LIMIT | 50 | Quota mensuel gratuit |
AI_CACHE_TTL_SECONDS | 86400 | TTL cache (0 = désactivé) |
AI_REQUEST_TIMEOUT | 30 | Timeout HTTP provider (s) |
AI_MAX_INPUT_LENGTH | 5000 | Taille 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 prompts | Non — modifications directes en base |
| Stats/dashboard IA dans le backoffice | Non — 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 mensuel | Non — ils s'accumulent |
Job PruneAiCache planifié | Code présent, non branché au scheduler |
Limite AI_MAX_INPUT_LENGTH appliquée | Config présente, non vérifiée dans les Requests |