Construire une veille technique automatisée
Ce tutoriel vous guide dans la construction d'un pipeline de veille technique de bout en bout. À la fin, vous aurez une chaîne qui, à cadence régulière, collecte des articles depuis des flux RSS/Atom, en extrait le contenu, le résume, écarte les doublons, et dépose des propositions de billets prêtes à être relues par un humain.
Le principe directeur que nous suivrons : la machine propose, l'humain publie. Le pipeline ne diffuse jamais rien tout seul. Le pourquoi de ce choix (human-in-the-loop) est expliqué à part — voir « Pourquoi la veille laisse l'IA proposer et l'humain trancher ». Nous, ici, nous le construisons.
Ce dont vous avez besoin
- Un langage back-end capable de tâches planifiées et de traitement asynchrone (l'exemple est en PHP/Symfony, mais la démarche se transpose).
- Un accès à un modèle de langage (LLM) via une API, pour le résumé.
- Un magasin de persistance pour suivre l'état des items collectés.
Ce tutoriel reste volontairement agnostique du fournisseur, des versions et de l'hébergement. Concentrez-vous sur la forme du pipeline ; les détails d'exploitation sont vos choix.
Le plan d'ensemble
Nous allons assembler le pipeline en cinq étapes, chacune isolée derrière une petite frontière (une interface). Cette séparation vous permettra de tester et de remplacer chaque brique indépendamment.
- Poser le squelette : une tâche planifiée qui déclenche un traitement asynchrone.
- Collecter les sources (flux RSS/Atom).
- Dédupliquer (URL + empreinte de contenu).
- Extraire et résumer le contenu.
- Écrire une proposition — jamais une publication.
Allons-y.
Étape 1 — Le squelette : planifier et traiter en asynchrone
Deux briques suffisent à structurer tout le reste.
- Un planificateur (scheduler applicatif) : il déclenche une tâche à intervalle régulier, comme un cron, mais piloté par le code.
- Un worker asynchrone : il traite les messages en arrière-plan. On y gagne la résilience (reprise après incident), le découplage, et la possibilité de paralléliser.
L'idée : le planificateur émet un message « collecter la veille » à la cadence voulue ; un handler exécute le travail, hors du cycle web.
Commençons par le message et son handler. Le message ne transporte que le minimum — ici, le thème à collecter.
final class CollecterVeille { public function __construct(public string $theme = 'ia') {} } #[AsMessageHandler] final class CollecterVeilleHandler { public function __construct( private Collecteur $collecteur, // récupère les items des flux private Deduplicateur $dedup, // écarte le déjà -vu private Resumeur $resumeur, // résume via le LLM private RedacteurProposition $redacteur, // écrit une proposition ) {} public function __invoke(CollecterVeille $msg): void { foreach ($this->collecteur->collecter($msg->theme) as $item) { if ($this->dedup->dejaVu($item)) { continue; } $resume = $this->resumeur->resumer($item); // garde la source + le lien $this->redacteur->proposer($msg->theme, $item, $resume); // brouillon } } }
Remarquez que le handler ne fait qu'orchestrer : chaque tâche lourde est
déléguée à un collaborateur derrière une interface (Collecteur,
Deduplicateur, Resumeur, RedacteurProposition). Nous allons remplir ces
briques une à une.
Planifions maintenant l'émission du message.
#[AsSchedule('veille')] final class PlanificationVeille implements ScheduleProviderInterface { public function getSchedule(): Schedule { return (new Schedule())->add( RecurringMessage::cron('0 7 * * *', new CollecterVeille('ia')) ); } }
Ici, une passe quotidienne. Extrayez la cadence et la liste des thèmes dans votre configuration plutôt que de les coder en dur — vous voudrez les ajuster sans toucher au code.
Pour faire tourner la chaîne, lancez le worker qui consomme les messages du
planificateur. Le planificateur émet CollecterVeille à l'heure prévue ; le
handler prend le relais et traite chaque item. Vous avez le squelette : il ne
fait encore rien d'utile, mais il tourne. Remplissons les briques.
Étape 2 — Collecter les sources (RSS/Atom)
Un collecteur prend une source (un flux) et rend une liste d'items normalisés. Modélisez d'abord un item minimal, immuable, qui voyage dans le pipeline.
final readonly class ItemVeille { public function __construct( public string $url, public string $titre, public string $contenuBrut = '', ) {} }
Le collecteur récupère le flux, le parse, et produit un ItemVeille par entrée.
Cachez le format derrière une interface : RSS et Atom diffèrent, et vous
voudrez peut-être ajouter d'autres types de sources plus tard.
interface Collecteur { public function supporte(string $type): bool; // 'rss' | 'atom' | ... /** @return iterable<ItemVeille> */ public function collecter(string $theme): iterable; }
Un bon réflexe : un registre de collecteurs qui, pour un type de source
donné, retourne le premier collecteur dont supporte() répond vrai. Ajouter un
nouveau type de source revient alors à écrire une classe de plus — l'orchestration
ne bouge pas.
Respectez les sources. Récupérez de façon raisonnable (rythme mesuré, identifiant d'agent honnête) et conformez-vous aux conditions d'utilisation de chaque flux. On résume et on lie ; on ne recopie pas intégralement.
Étape 3 — Dédupliquer (URL + empreinte de contenu)
Un même article circule souvent sous plusieurs adresses : variantes de chemin, paramètres de suivi ajoutés au lien. Comparer les URLs ne suffit donc pas. On combine deux signaux :
- l'URL (après un nettoyage : on retire les paramètres de suivi) ;
- une empreinte du contenu (un hash du texte), qui rattrape les mêmes contenus servis sous des adresses différentes.
final class Deduplicateur { public function __construct(private VuStore $store) {} public function dejaVu(ItemVeille $item): bool { $urlNormalisee = $this->normaliserUrl($item->url); $empreinte = hash('sha256', $item->contenuBrut); if ($this->store->contientUrl($urlNormalisee) || $this->store->contientEmpreinte($empreinte)) { return true; } $this->store->enregistrer($urlNormalisee, $empreinte); return false; } }
Un item déjà vu n'est pas re-soumis au résumé : vous ne payez pas deux fois l'appel au LLM, et le relecteur humain ne voit pas de doublon. Persistez URL normalisée et empreinte dès la collecte, avant tout traitement coûteux.
Étape 4 — Extraire et résumer
Le flux ne donne souvent qu'un titre et un extrait. Si le contenu est trop maigre pour un résumé honnête, allez chercher le texte de l'article (extraction), puis résumez.
Garde-fou important : si vous n'avez pas assez de matière — page injoignable, contenu vide — ne résumez pas un titre seul. Résumer à partir de presque rien, c'est inviter l'hallucination. Préférez marquer l'item en échec et le signaler. Un manque visible vaut mieux qu'une fabrication invisible.
final class Resumeur { public function __construct( private ExtracteurTexte $extracteur, private ClientLLM $llm, ) {} public function resumer(ItemVeille $item): Resume { $contenu = $item->contenuBrut; if ($this->estInsuffisant($contenu)) { $contenu = $this->extracteur->extraire($item->url); } if ($this->estInsuffisant($contenu)) { throw new ContenuInsuffisantException($item->url); // → item en échec } // Le résumé conserve toujours le lien vers l'original. return $this->llm->resumer($item->titre, $item->url, $contenu); } }
Deux principes de robustesse à retenir dès maintenant :
- Isolez chaque item. Traitez-les un par un ; une erreur sur l'un
(
ContenuInsuffisantException, appel LLM en échec) ne doit pas faire tomber le lot. Attrapez, journalisez, passez au suivant. - Tracez les échecs. Enregistrez l'erreur pour diagnostic plutôt que de la laisser filer. Vous voudrez savoir pourquoi un item n'a pas abouti.
Ces deux comportements sont ce qui rend la promesse « l'humain décide » tenable dans la durée. Leur justification de conception est développée dans l'explanation.
Étape 5 — Proposer, jamais publier
Dernière brique, et la plus importante : le résultat n'est pas une publication. C'est une proposition — un brouillon avec un statut explicite, qui attend une décision humaine.
final class RedacteurProposition { public function proposer(string $theme, ItemVeille $item, Resume $resume): void { // Écrit un brouillon (Markdown, par exemple) dans un dossier de // propositions. Statut initial : « proposé », en attente de relecture. // Aucune diffusion ici. } }
La proposition porte au minimum : le titre, le résumé, le lien vers la source, le thème, et un statut. Le lien n'est pas décoratif : c'est le garde-fou anti-hallucination — le relecteur peut vérifier — et le respect de la source.
Modélisez le cycle de vie de l'item avec des statuts qui disent qui a la main :
en attente → proposé → puis, sur décision humaine, accepté ou rejeté, et
échec quand le traitement n'a pas pu aboutir. La bascule vers accepté ou
rejeté est un acte humain. Le pipeline s'arrête à proposé.
Facultatif — un pré-tri assisté. Vous pouvez ajouter une seconde passe d'IA qui émet un avis (favorable / défavorable / incertain) sur chaque proposition, pour alléger la relecture. Règle d'or : un avis incertain (ou une réponse illisible) ne tranche pas — l'item reste
proposéet remonte vers l'humain. L'IA peut aider à juger ; elle ne confisque jamais la décision. Le raisonnement complet est dans l'explanation.
Ce que vous avez construit
Vous avez un pipeline complet :
- une tâche planifiée émet un travail à cadence régulière ;
- un worker asynchrone l'exécute hors du cycle web ;
- des collecteurs agrègent les flux RSS/Atom ;
- une déduplication (URL + empreinte) écarte les doublons ;
- un résumé condense chaque article, avec extraction de secours et garde-fou anti-contenu-vide ;
- une proposition attend, avec un statut explicite, la décision d'un humain.
Chaque étape est derrière une interface : vous pouvez la stubber en test ou la remplacer sans toucher à l'orchestration.
Pour aller plus loin
- Le pourquoi du human-in-the-loop, des statuts et de la dégradation gracieuse : « Pourquoi la veille laisse l'IA proposer et l'humain trancher ».
- Les contrats précis d'un composant de ce type (entités, interfaces remplaçables, statuts, signatures) : « Bundle produit doc-IA ».
- Classement thématique. Nous avons collecté par thème unique. Pour plusieurs thèmes, un premier tri par correspondance de termes suffit ; à l'échelle, branchez un classement par similarité sémantique (embeddings) — idéalement en réutilisant un moteur de similarité déjà en place pour la recherche documentaire.