Checkpoint: Ajout de server/articleFetcher.ts (fetch HTTP + extraction 500 mots, timeout 5s, fallback silencieux). Intégration dans rssEngine.ts : si le résumé RSS est court (<150 car.), le 1er paragraphe de l'article est récupéré et transmis à classifyArticle. Signature de classifyArticle mise à jour avec paramètre contenuPage optionnel. 0 erreur TypeScript, 21 tests passés.

This commit is contained in:
Manus
2026-06-19 09:27:59 -04:00
parent 165599bc83
commit dde3d8b6b8
5 changed files with 130 additions and 10 deletions

View File

@@ -1,4 +1,4 @@
{ {
"version": "c4f5048d", "version": "6a49fe67",
"timestamp": 1781875470326 "timestamp": 1781875679562
} }

View File

@@ -99,16 +99,22 @@ function parseJsonSafe<T>(text: string): T | null {
* Détermine simultanément la pertinence et le type de veille. * Détermine simultanément la pertinence et le type de veille.
* Retourne toujours un résultat (fallback sur rules en cas d'erreur). * Retourne toujours un résultat (fallback sur rules en cas d'erreur).
* *
* @param titre Titre de l'article * @param titre Titre de l'article
* @param resume Résumé / description de l'article * @param resume Résumé / description de l'article
* @param fallbackFn Fonction de fallback appelée si l'IA échoue * @param fallbackFn Fonction de fallback appelée si l'IA échoue
* @param contenuPage Premiers paragraphes de l'article complet (optionnel, améliore la précision)
*/ */
export async function classifyArticle( export async function classifyArticle(
titre: string, titre: string,
resume: string, resume: string,
fallbackFn: () => { typeVeille: AiTypeVeille } fallbackFn: () => { typeVeille: AiTypeVeille },
contenuPage?: string | null
): Promise<AiClassificationResult> { ): Promise<AiClassificationResult> {
const userContent = `Titre : ${titre}\n\nRésumé : ${resume}`; // Construire le contenu utilisateur en enrichissant avec le contenu de la page si disponible
const contenuSection = contenuPage
? `\n\nExtrait de l'article (premiers paragraphes) : ${contenuPage}`
: "";
const userContent = `Titre : ${titre}\n\nRésumé RSS : ${resume}${contenuSection}`;
try { try {
const response = await invokeLLM({ const response = await invokeLLM({

94
server/articleFetcher.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* articleFetcher.ts
*
* Récupère le contenu textuel des 500 premiers mots d'un article web
* pour enrichir la classification IA quand le résumé RSS est trop court.
*
* Stratégie :
* - Fetch HTTP avec timeout 5 secondes
* - Extraction des balises <p>, <h1><h3> du HTML brut (sans dépendance lourde)
* - Limite à 500 mots pour rester dans le contexte LLM
* - Retourne null en cas d'erreur (timeout, 403, paywall, JS-only…) — silencieux
*/
const FETCH_TIMEOUT_MS = 5_000;
const MAX_WORDS = 500;
/**
* Extrait le texte brut des balises de contenu d'un HTML.
* Approche légère sans parser DOM complet.
*/
function extractTextFromHtml(html: string): string {
// Supprimer les balises script, style, nav, header, footer, aside
let cleaned = html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<nav[\s\S]*?<\/nav>/gi, " ")
.replace(/<header[\s\S]*?<\/header>/gi, " ")
.replace(/<footer[\s\S]*?<\/footer>/gi, " ")
.replace(/<aside[\s\S]*?<\/aside>/gi, " ")
.replace(/<noscript[\s\S]*?<\/noscript>/gi, " ");
// Extraire le contenu des balises de texte pertinentes
const contentTags = cleaned.match(/<(p|h[1-3]|li|blockquote)[^>]*>([\s\S]*?)<\/\1>/gi) ?? [];
const texts = contentTags.map((tag) =>
tag
.replace(/<[^>]+>/g, " ") // supprimer les balises HTML restantes
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&nbsp;/g, " ")
.replace(/&quot;/g, '"')
.replace(/&#\d+;/g, " ")
.replace(/\s+/g, " ")
.trim()
);
return texts.filter((t) => t.length > 20).join(" ");
}
/**
* Récupère les premiers ~500 mots du contenu d'un article web.
* Retourne null si l'article est inaccessible ou si le contenu est trop pauvre.
*
* @param url URL de l'article
* @returns Texte extrait (500 mots max) ou null
*/
export async function fetchArticleFirstParagraph(url: string): Promise<string | null> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
const response = await fetch(url, {
signal: controller.signal,
headers: {
"User-Agent":
"Mozilla/5.0 (compatible; VeilleBot/1.0; +https://itinova.fr)",
Accept: "text/html,application/xhtml+xml",
"Accept-Language": "fr-FR,fr;q=0.9",
},
});
clearTimeout(timer);
if (!response.ok) return null;
const contentType = response.headers.get("content-type") ?? "";
if (!contentType.includes("text/html")) return null;
const html = await response.text();
const text = extractTextFromHtml(html);
if (!text || text.length < 50) return null;
// Limiter à MAX_WORDS mots
const words = text.split(/\s+/);
const truncated = words.slice(0, MAX_WORDS).join(" ");
return truncated.length > 50 ? truncated : null;
} catch {
// Timeout, réseau, CORS, paywall, etc. — silencieux
return null;
}
}

View File

@@ -23,6 +23,7 @@ import {
} from "../drizzle/schema"; } from "../drizzle/schema";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { classifyArticle, generateSummary } from "./aiClassifier"; import { classifyArticle, generateSummary } from "./aiClassifier";
import { fetchArticleFirstParagraph } from "./articleFetcher";
// ─── Types internes ─────────────────────────────────────────────────────────── // ─── Types internes ───────────────────────────────────────────────────────────
@@ -351,9 +352,14 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
const dedupKey = dedupHash(normalizedTitle + "|" + (feed.feedType ?? "")); const dedupKey = dedupHash(normalizedTitle + "|" + (feed.feedType ?? ""));
if (feed.feedType === "veille") { if (feed.feedType === "veille") {
// ── Classification IA (avec fallback sur mots-clés) ────────────────── // ── Classification IA (avec fallback sur mots-clés) ──────────────────────────────────────
const { niveau, territoire } = detectVeilleNiveauTerritoire(fullText); const { niveau, territoire } = detectVeilleNiveauTerritoire(fullText);
// Enrichissement : récupérer le 1er paragraphe si le résumé RSS est court (<150 car.)
const contenuPage = description.length < 150 && link
? await fetchArticleFirstParagraph(link)
: null;
const aiResult = await classifyArticle( const aiResult = await classifyArticle(
title, title,
description, description,
@@ -363,7 +369,8 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
typeVeille: (matchedRule?.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as typeVeille: (matchedRule?.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
"reglementaire" | "concurrentielle" | "technologique" | "informationnelle", "reglementaire" | "concurrentielle" | "technologique" | "informationnelle",
}; };
} },
contenuPage
); );
const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
@@ -420,12 +427,18 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
// ── Classification IA pour les AAP ─────────────────────────────────── // ── Classification IA pour les AAP ───────────────────────────────────
const { region, departement } = detectAapGeo(fullText); const { region, departement } = detectAapGeo(fullText);
// Enrichissement : récupérer le 1er paragraphe si le résumé RSS est court (<150 car.)
const contenuPageAap = description.length < 150 && link
? await fetchArticleFirstParagraph(link)
: null;
const aiResult = await classifyArticle( const aiResult = await classifyArticle(
title, title,
description, description,
() => ({ () => ({
typeVeille: "informationnelle" as const, typeVeille: "informationnelle" as const,
}) }),
contenuPageAap
); );
const categorie = (feed.defaultCategorieAap ?? "Autre") as const categorie = (feed.defaultCategorieAap ?? "Autre") as

View File

@@ -155,3 +155,10 @@
- [x] aiClassifier.ts : remplacer le prompt de pertinence générique ESMS par une définition explicite des 5 secteurs (handicap, précarité/logement, protection de l'enfance, personnes âgées, sanitaire SMR) - [x] aiClassifier.ts : remplacer le prompt de pertinence générique ESMS par une définition explicite des 5 secteurs (handicap, précarité/logement, protection de l'enfance, personnes âgées, sanitaire SMR)
- [x] 0 erreur TypeScript, 21 tests passés - [x] 0 erreur TypeScript, 21 tests passés
- [x] Checkpoint - [x] Checkpoint
## Récupération du 1er paragraphe pour la classification IA
- [x] Créer server/articleFetcher.ts : fetch HTTP avec timeout 5s, extraction des 500 premiers mots du contenu textuel (balises p, h1-h3), fallback silencieux si erreur
- [x] Intégrer articleFetcher dans rssEngine.ts : enrichir le contenu envoyé à classifyArticle avec le 1er paragraphe récupéré
- [x] Mettre à jour la signature de classifyArticle pour accepter un paramètre optionnel contenuPage
- [x] 0 erreur TypeScript, tests passés
- [x] Checkpoint