IAlouette, Gentille IAlouette : perspective d'avenir ou miroir aux alouettes ?
Découvrez comment l'IA booste le code... ou pas ! Assistez aux débats sur l'avenir du développement avec l'aide IA.
Découvrez comment automatiser le SEO avec l'IA, épatant plus qu'un poème de Baudelaire, mais sans le spleen.
Avant de vous défoncer le crâne avec du code technique comme un pervers narcissique avec son ego, posons-nous la question fondamentale : pourquoi diable vouloir confier la génération des métadonnées SEO à une intelligence artificielle ?
Et surtout, parce que ça marche ! L'IA moderne, particulièrement ces gros modèles de langage comme GPT, peut générer des titres clickbait, des descriptions à la con et des mots-clés qui rivalisent avec ceux produits par des humains. Pas tous les humains, hein. Certains continuent de produire des merveilles d'éloquence. Mais pour le SEO basique, l'IA fait le taf, et elle le fait sans se plaindre ni demander d'augmentation.
Notre système de génération automatisée de SEO est construit sur Symfony, ce framework PHP que j'aime d'amour.
L'architecture repose sur plusieurs composants clés qui s'orchestrent ensemble comme un gang de politiciens corrompus :
Maintenant, explorons chaque composant comme si nous étions des archéologues pervers découvrant un cadavre particulièrement bien conservé.
Commençons par le commencement : la configuration du bundle qui nous permet de dialoguer avec les grands modèles de langage. Voici comment j'ai configuré mon llm_chain.yaml
:
services:
_defaults:
autowire: true
autoconfigure: true
PhpLlm\LlmChain\Chain\Toolbox\Tool\Clock: null
llm_chain:
platform:
openai:
api_key: '%env(OPENAI_API_KEY)%'
chain:
default:
platform: 'llm_chain.platform.openai'
model:
name: 'GPT'
system_prompt: >
Tu es un expert SEO qui rédige des titres, des meta descriptions et des mots-clés accrocheurs pour des articles de blog techniques avec une touche d'humour acide et ironique.
Cette configuration est aussi simple qu'un député qui détourne des fonds publics : nous définissons la plateforme (OpenAI), nous précisons le modèle, et nous donnons un prompt système qui indique au modèle qu'il doit jouer le rôle d'un expert SEO avec un sens de l'humour acéré. Un peu comme moi, mais sans les injures et les menaces de mort.
Le générateur de SEO est le cœur battant de notre système. C'est lui qui dialogue avec l'IA et transforme ses réponses en métadonnées structurées. Analysons cette classe comme un flic analyserait un cadavre dans une série B :
class ContentSeoGenerator
{
private const int MAX_TITLE_LENGTH = 60;
private const int MAX_DESCRIPTION_LENGTH = 160;
private const int MAX_KEYWORDS = 5;
// Estimation approximative du nombre de tokens pour le calcul du coût
private const float ESTIMATED_INPUT_TOKENS_PER_CHAR = 0.25;
private const int ESTIMATED_OUTPUT_TOKENS = 200;
public function __construct(
private readonly ChainInterface $chain,
private readonly LoggerInterface $logger,
private readonly LlmApiLimitService $apiLimitService,
) {
}
public function generateSeo(AbstractContent $content, bool $forceRegeneration = false): void
{
$apiUsage = null;
try {
$contentText = $content->getContent();
$contentTitle = $content->getTitle();
$seo = $content->getSeo();
// Vérifications préliminaires : contenu vide, SEO déjà généré, etc.
// [Code de vérification omis pour clarté]
// Vérifier si on peut faire un appel API (limite budgétaire mensuelle)
if (!$this->apiLimitService->canMakeApiCall()) {
$this->logger->warning('Monthly LLM API budget limit reached, skipping SEO generation');
return;
}
// Commencer à tracer l'appel API
$apiUsage = $this->apiLimitService->startApiCall($content);
$truncatedContent = $this->truncateContent($contentText);
// Construction du prompt
$promptText = "Voici le titre d'un article de blog: \"{$contentTitle}\"\n\n";
$promptText .= "Et voici le contenu de l'article:\n";
$promptText .= "\"\"\"\n{$truncatedContent}\n\"\"\"\n\n";
$promptText .= "En tant qu'expert SEO, génère pour cet article:\n";
$promptText .= '1. Un titre SEO optimisé (max '.self::MAX_TITLE_LENGTH." caractères)\n";
$promptText .= '2. Une meta description accrocheuse (max '.self::MAX_DESCRIPTION_LENGTH." caractères)\n";
$promptText .= '3. Une liste de '.self::MAX_KEYWORDS." mots-clés pertinents, séparés par des virgules\n\n";
$promptText .= 'Réponds uniquement au format JSON avec les champs title, description et keywords (tableau de strings).';
// Préparation et envoi du message
$messages = new MessageBag();
$messages->add(new UserMessage(new Text($promptText)));
// Appel à l'API LLM via le service Chain
$response = $this->chain->call($messages);
$result = $response->getContent();
// Traitement de la réponse
$cleanedResult = preg_replace('/```json\s*|\s*```/', '', trim($result));
$seoData = json_decode((string) $cleanedResult, true, 512, JSON_THROW_ON_ERROR);
// Validation et traitement des données reçues
// [Code de validation omis pour clarté]
// Mise à jour des métadonnées SEO
$seo->setTitle($seoData['title']);
$seo->setDescription($seoData['description']);
$seo->setKeywords($seoData['keywords']);
// Enregistrement de l'utilisation de l'API
// [Code d'enregistrement omis pour clarté]
} catch (\Throwable $throwable) {
// Gestion des erreurs
// [Code de gestion d'erreur omis pour clarté]
}
}
private function truncateContent(string $content): string
{
// Limite pour ne pas dépasser le nombre de tokens
$maxChars = 5000;
if (mb_strlen($content) > $maxChars) {
return mb_substr($content, 0, $maxChars).'...';
}
return $content;
}
}
Ce code est structuré comme une stratégie d'évasion fiscale bien rodée :
Chain
fourni par le bundle.Tout cela est emballé dans un joli try/catch
, car comme pour une partouze avec des inconnus, il vaut mieux prévoir des protections.
Parlons maintenant d'un élément crucial mais souvent négligé : la gestion des coûts. Car oui, appeler une API d'IA, ce n'est pas gratuit. Sauf si vous êtes le PDG d'OpenAI, mais dans ce cas, vous êtes probablement trop occupé à compter vos milliards pour lire ce billet.
class LlmApiLimitService
{
private const float COST_PER_1K_TOKENS_INPUT = 0.0015;
private const float COST_PER_1K_TOKENS_OUTPUT = 0.002;
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly LlmApiUsageRepository $apiUsageRepository,
private readonly LoggerInterface $logger,
#[Autowire('%llm_monthly_budget%')]
private readonly float $monthlyBudget,
) {
}
public function canMakeApiCall(): bool
{
return !$this->apiUsageRepository->isMonthlyLimitReached($this->monthlyBudget);
}
public function getRemainingBudget(): float
{
return $this->apiUsageRepository->getRemainingBudget($this->monthlyBudget);
}
public function startApiCall(AbstractContent $content, string $action = 'generate_seo'): LlmApiUsage
{
$apiUsage = new LlmApiUsage();
$apiUsage->setEntityType($content::class);
$apiUsage->setEntityId($content->getId() ?? 0);
$apiUsage->setAction($action);
$this->entityManager->persist($apiUsage);
$this->entityManager->flush();
return $apiUsage;
}
public function recordSuccessfulApiCall(
LlmApiUsage $apiUsage,
int $inputTokenCount,
int $outputTokenCount,
?array $metadata = null,
): void {
$cost = $this->calculateCost($inputTokenCount, $outputTokenCount);
$apiUsage->setSuccess(true);
$apiUsage->setCost((string) $cost);
$apiUsage->setTokenCount($inputTokenCount + $outputTokenCount);
$apiUsage->setMetadata($metadata);
$this->entityManager->flush();
$this->logger->info('LLM API call recorded successfully', [
'cost' => $cost,
'entity_id' => $apiUsage->getEntityId(),
'entity_type' => $apiUsage->getEntityType(),
'remaining_budget' => $this->getRemainingBudget(),
]);
}
public function recordFailedApiCall(
LlmApiUsage $apiUsage,
string $errorMessage,
?array $metadata = null,
): void {
// [Code d'enregistrement d'échec omis pour clarté]
}
private function calculateCost(int $inputTokenCount, int $outputTokenCount): float
{
$inputCost = ($inputTokenCount / 1000) * self::COST_PER_1K_TOKENS_INPUT;
$outputCost = ($outputTokenCount / 1000) * self::COST_PER_1K_TOKENS_OUTPUT;
return round($inputCost + $outputCost, 4);
}
public function getMonthlyBudget(): float
{
return $this->monthlyBudget;
}
}
Ce service est le ministre des finances de notre système. Il veille à ce que nous ne dépassions pas notre budget mensuel alloué aux appels d'API. Il enregistre chaque utilisation, calcule les coûts associés, et nous permet de suivre nos dépenses avec la précision d'un avare comptant ses pièces.
Le calcul des coûts est basé sur le nombre de tokens utilisés, avec un tarif différent pour les tokens d'entrée (0,0015 $ par 1000 tokens) et les tokens de sortie (0,002 $ par 1000 tokens). C'est comme les toilettes payantes : tu payes pour y entrer, mais tu payes encore plus pour en sortir.
Pour suivre nos coûts avec précision, nous avons créé une entité dédiée :
#[ORM\Entity(repositoryClass: LlmApiUsageRepository::class)]
#[ORM\Table(name: 'llm_api_usage')]
#[ORM\Index(columns: ['created_at'], name: 'llm_api_usage_created_at_idx')]
class LlmApiUsage
{
use TimestampableEntity;
#[ORM\Id]
#[ORM\GeneratedValue(strategy: 'IDENTITY')]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $entityType;
#[ORM\Column]
private int $entityId;
#[ORM\Column(length: 50)]
private string $action = 'generate_seo';
#[ORM\Column(nullable: true)]
private ?bool $success = null;
#[ORM\Column(type: Types::DECIMAL, precision: 10, scale: 4)]
private string $cost = '0.0000';
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $metadata = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $errorMessage = null;
#[ORM\Column(type: Types::SMALLINT)]
private int $tokenCount = 0;
// [Getters et setters omis pour clarté]
}
Cette entité enregistre tout ce que nous devons savoir sur chaque appel d'API :
C'est comme un détective privé qui suit tous vos mouvements bancaires. Flippant, mais efficace.
Pour interroger notre historique d'utilisation, nous avons créé un repository spécialisé :
class LlmApiUsageRepository extends ServiceEntityRepository
{
public function getCurrentMonthTotalCost(): float
{
$startOfMonth = new \DateTimeImmutable('first day of this month midnight');
$qb = $this->createQueryBuilder('u');
$qb->select('SUM(u.cost) as total_cost')
->where('u.createdAt >= :start_date')
->andWhere('u.success = :success')
->setParameter('start_date', $startOfMonth)
->setParameter('success', true);
$result = $qb->getQuery()->getSingleScalarResult();
return (float) ($result ?? 0);
}
public function isMonthlyLimitReached(float $monthlyLimit = 5.0): bool
{
$currentUsage = $this->getCurrentMonthTotalCost();
return $currentUsage >= $monthlyLimit;
}
public function getRemainingBudget(float $monthlyLimit = 5.0): float
{
$currentUsage = $this->getCurrentMonthTotalCost();
$remaining = $monthlyLimit - $currentUsage;
return max(0, $remaining);
}
public function findByMonth(\DateTimeInterface $date): array
{
// [Code de recherche par mois omis pour clarté]
}
public function getDailyUsageStats(\DateTimeInterface $date): array
{
// [Code de statistiques quotidiennes omis pour clarté]
}
}
Ce repository nous permet de répondre à des questions cruciales comme :
C'est comme avoir un contrôleur fiscal personnel pour notre consommation d'IA, mais sans la menace de prison pour fraude.
Pour traiter les demandes de génération de SEO de manière asynchrone dans certains cas (comme lors des mises à jour automatiques de contenu), nous utilisons le composant Messenger de Symfony. Commençons par le message :
class GenerateSeoMessage
{
/**
* @param array<string, mixed> $options Options like ['force' => bool]
*/
public function __construct(
private readonly int $contentId,
private readonly string $contentClass,
private readonly array $options = [],
) {
}
// [Getters omis pour clarté]
}
Ce message est aussi simple qu'un tweet présidentiel : il contient l'ID du contenu, sa classe (pour pouvoir le retrouver), et quelques options. Rien de révolutionnaire, mais efficace.
Le handler, lui, est un peu plus sophistiqué :
#[AsMessageHandler]
class GenerateSeoHandler
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ContentSeoGenerator $seoGenerator,
private readonly LoggerInterface $logger,
) {
}
public function __invoke(GenerateSeoMessage $message): void
{
try {
$contentId = $message->getContentId();
$contentClass = $message->getContentClass();
// Valider de la classe
if (!class_exists($contentClass) || !is_subclass_of($contentClass, AbstractContent::class)) {
$this->logger->error('Invalid content class in GenerateSeoMessage');
return;
}
// Trouver l'entité du contenu
$content = $this->entityManager->find($contentClass, $contentId);
if ($content === null) {
$this->logger->error('Content not found for SEO generation');
return;
}
// Vérifier si le paramètre doit être forcer
$forceRegeneration = false;
if (isset($message->getOptions()['force']) && $message->getOptions()['force'] === true) {
$forceRegeneration = true;
}
// Générer les metadonnées (seulement si les champs sont vides et que force est à true)
$this->seoGenerator->generateSeo($content, $forceRegeneration);
// Sauver l'entité mise à jour
$this->entityManager->flush();
$this->logger->info('SEO metadata generated and saved');
} catch (\Throwable $throwable) {
$this->logger->error('Error in GenerateSeoHandler');
}
}
}
Ce handler est comme un videur efficace dans une boîte de nuit :
Dans la version récente du système, nous avons migré le traitement manuel vers une approche synchrone, directement depuis la commande console. Voici un extrait de la commande qui montre comment nous avons implémenté ce changement :
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title('Génération des métadonnées SEO pour le contenu existant');
// Afficher la version et le mode de traitement
$io->section('🚀 Configuration');
$io->writeln([
'• <info>Mode de traitement</info>: <comment>Synchrone (direct)</comment>',
'• <info>Version</info>: <comment>1.0</comment>',
]);
// [Code omis pour clarté]
// Si un ID spécifique est fourni, traiter uniquement ce contenu
if ($contentId !== null) {
$io->section('🔍 Traitement d\'un contenu spécifique');
$content = $this->contentRepository->find((int) $contentId);
if (!$dryRun) {
$io->writeln("\n<options=bold>Génération en cours...</options=bold>");
$startTime = microtime(true);
// Exécuter directement le traitement au lieu d'utiliser le message bus
$result = $this->processSeoGeneration($content, $force, $io);
$endTime = microtime(true);
$duration = round($endTime - $startTime, 2);
// [Code d'affichage des résultats omis pour clarté]
}
}
// [Code omis pour clarté]
}
/**
* Traite directement la génération SEO sans passer par le message bus.
*/
private function processSeoGeneration(AbstractContent $content, bool $force = false, ?SymfonyStyle $io = null): bool
{
try {
$contentClass = $content::class;
$contentId = $content->getId();
if ($io instanceof SymfonyStyle) {
$io->writeln('<fg=blue>• Préparation de la génération SEO...</>');
}
// Créer le message comme il aurait été envoyé par le message bus
$message = new GenerateSeoMessage(
contentId: $contentId,
contentClass: $contentClass,
options: ['force' => $force],
);
if ($io instanceof SymfonyStyle) {
$io->writeln('<fg=blue>• Appel du traitement de génération SEO...</>');
}
// Créer une instance du handler avec nos services injectés
$handler = new \App\MessageHandler\Seo\GenerateSeoHandler(
$this->entityManager,
$this->seoGenerator,
$this->logger,
);
// Invoquer directement le handler
$handler->__invoke($message);
if ($io instanceof SymfonyStyle) {
$io->writeln('<fg=green>• Génération terminée</>');
}
return true;
} catch (\Throwable $throwable) {
// [...gestion des erreurs...]
return false;
}
}
Ce passage à un traitement synchrone pour la commande console offre plusieurs avantages :
C'est comme passer de la livraison par La Poste à la remise en main propre : plus rapide, plus fiable, mais toujours aussi chiant à implémenter.
Pour déclencher la génération de SEO au bon moment, nous utilisons un subscriber qui écoute les événements Doctrine et gère les mises à jour de contenu :
#[AsDoctrineListener(event: Events::postUpdate)]
#[AsDoctrineListener(event: Events::postPersist)]
#[WhenNot(env: 'dev')]
readonly class ContentSeoSubscriber implements EventSubscriberInterface
{
public function __construct(
private MessageBusInterface $messageBus,
private EventDispatcherInterface $eventDispatcher,
) {
}
public static function getSubscribedEvents(): array
{
return [
ContentUpdatedEvent::class => 'onContentUpdated',
];
}
public function postUpdate(AbstractContent $content, PostUpdateEventArgs $args): void
{
if ($this->shouldProcessEntity($content)) {
$this->eventDispatcher->dispatch(new ContentUpdatedEvent($content, false));
}
}
public function postPersist(AbstractContent $content, PostPersistEventArgs $args): void
{
if ($this->shouldProcessEntity($content)) {
$this->eventDispatcher->dispatch(new ContentUpdatedEvent($content, true));
}
}
public function onContentUpdated(ContentUpdatedEvent $event): void
{
$content = $event->getContent();
// Générer le SEO uniquement pour le contenu en ligne ou sur le point d'être publié
if ($content->isOnline() !== true) {
return;
}
// Pour les nouveaux contenus, on force la génération SEO
// Pour les mises à jour, on ne régénère que si les champs SEO sont vides
$forceRegeneration = $event->isNewContent();
// Soumettre le message pour un traitement asynchrone
$this->messageBus->dispatch(
new GenerateSeoMessage(
contentId: $content->getId(),
contentClass: $content::class,
options: ['force' => $forceRegeneration],
),
);
}
private function shouldProcessEntity(object $entity): bool
{
return $entity instanceof AbstractContent;
}
}
Ce subscriber est stratégiquement positionné pour observer les changements de contenu comme un voyeur devant la fenêtre d'une jolie fille :
postUpdate
et postPersist
de Doctrine.ContentUpdatedEvent
.GenerateSeoMessage
dans la file d'attente.Notez l'attribut #[WhenNot(env: 'dev')]
qui désactive ce subscriber en environnement de développement.
Pour suivre nos coûts d'utilisation de l'API, nous avons créé une commande console pratique :
#[AsCommand(
name: 'app:seo:llm-usage',
description: 'Affiche les statistiques d\'utilisation de l\'API LLM pour la génération SEO',
)]
class LlmApiUsageCommand extends Command
{
public function __construct(
private readonly LlmApiUsageRepository $apiUsageRepository,
private readonly LlmApiLimitService $apiLimitService,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption(
'month',
'm',
InputOption::VALUE_OPTIONAL,
'Le mois à consulter (format: YYYY-MM)',
)
->addOption(
'detailed',
'd',
InputOption::VALUE_NONE,
'Afficher les données détaillées',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title("Statistiques d'utilisation de l'API LLM");
// [Code d'affichage des statistiques omis pour clarté]
return Command::SUCCESS;
}
}
Cette commande est comme un relevé de compte bancaire pour nos appels d'API. Elle affiche :
Pratique pour savoir si vous pouvez encore vous permettre de générer du SEO pour vos 200 articles de blog sur "Comment baiser sa belle-sœur" ou si vous devez rationner vos appels d'API comme des cigarettes en prison.
Pour stocker les métadonnées SEO générées, nous utilisons une classe embeddable :
#[ORM\Embeddable]
class Seo
{
#[ORM\Column(length: 255, nullable: true)]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $description = null;
/**
* @var string[]|null
*/
#[ORM\Column(nullable: true)]
#[Assert\All([
new Assert\Type(type: 'string'),
new Assert\Length(max: 100),
])]
private ?array $keywords = null;
/**
* Valid values: index, noindex, follow, nofollow, none, noarchive, etc.
*/
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Regex(
pattern: '/^(index|noindex|follow|nofollow|none|noarchive|nosnippet|notranslate|noimageindex)(\s*,\s*(index|noindex|follow|nofollow|none|noarchive|nosnippet|notranslate|noimageindex))*$/',
message: 'La valeur robots doit être une combinaison valide de directives séparées par des virgules',
)]
private ?string $robots = 'index, follow';
// [Getters et setters omis pour clarté]
}
Cette classe embeddable est incorporée dans nos entités de contenu pour stocker les métadonnées SEO. Elle contient :
C'est comme un petit coffre-fort à l'intérieur de nos entités de contenu, où les métadonnées SEO peuvent se cacher des regards indiscrets de Google.
Nous avons parcouru ensemble l'implémentation d'un système de génération automatisée de métadonnées SEO utilisant l'intelligence artificielle. Ce système :
L'évolution la plus récente du système a été la migration du traitement manuel via la commande console vers un mode synchrone, offrant ainsi une meilleure expérience utilisateur et des retours d'information immédiats. Cette approche hybride nous permet de profiter du meilleur des deux mondes, comme un politicien qui promet tout à tout le monde.
Ce système nous permet de gagner un temps considérable tout en maintenant une qualité de SEO élevée. Bien sûr, il ne remplace pas complètement un expert SEO humain (rassurez-vous, connards de consultants au prix horaire exorbitant), mais il automatise efficacement une tâche répétitive et chronophage.
Si vous souhaitez implémenter un système similaire, voici quelques conseils :
composer require php-llm/llm-chain-bundle
Si un jour les IA prennent le contrôle du monde, rappelez-vous que c'est probablement parce que quelqu'un leur a demandé d'optimiser le taux de conversion "à tout prix". Comme quoi, même les robots peuvent devenir des capitalistes sans scrupules.
Ce site utilise Umami pour analyser le trafic de manière anonyme. Acceptez-vous la collecte de données anonymes ?