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:
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": "c4f5048d",
|
"version": "6a49fe67",
|
||||||
"timestamp": 1781875470326
|
"timestamp": 1781875679562
|
||||||
}
|
}
|
||||||
@@ -102,13 +102,19 @@ function parseJsonSafe<T>(text: string): T | null {
|
|||||||
* @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
94
server/articleFetcher.ts
Normal 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(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/"/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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
7
todo.md
7
todo.md
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user