Compare commits

...

2 Commits

10 changed files with 647 additions and 25 deletions

View File

@@ -0,0 +1,162 @@
{
"query": "SHOW COLUMNS FROM veille_items;",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.63b125a8f9ca --database VepzDyqR8YkJNcqpZ729Bw --execute SHOW COLUMNS FROM veille_items;",
"rows": [
{
"Field": "id",
"Type": "int",
"Null": "NO",
"Key": "PRI",
"Default": "NULL",
"Extra": "auto_increment"
},
{
"Field": "dedupKey",
"Type": "varchar(64)",
"Null": "NO",
"Key": "UNI",
"Default": "NULL",
"Extra": ""
},
{
"Field": "titre",
"Type": "text",
"Null": "NO",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "categorie",
"Type": "varchar(128)",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "niveau",
"Type": "varchar(128)",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "territoire",
"Type": "varchar(255)",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "resume",
"Type": "text",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "source",
"Type": "varchar(512)",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "passage",
"Type": "text",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "lien",
"Type": "text",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "typeVeille",
"Type": "enum('reglementaire','concurrentielle','technologique','informationnelle')",
"Null": "NO",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "datePublication",
"Type": "timestamp",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "importedAt",
"Type": "timestamp",
"Null": "NO",
"Key": "",
"Default": "CURRENT_TIMESTAMP",
"Extra": ""
},
{
"Field": "territoires",
"Type": "text",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "iaRelevant",
"Type": "tinyint(1)",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "iaCategorie",
"Type": "varchar(128)",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "iaClassifiedBy",
"Type": "enum('ia','rules')",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "iaReason",
"Type": "text",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
},
{
"Field": "iaResume",
"Type": "text",
"Null": "YES",
"Key": "",
"Default": "NULL",
"Extra": ""
}
],
"messages": [],
"stdout": "Field\tType\tNull\tKey\tDefault\tExtra\nid\tint\tNO\tPRI\tNULL\tauto_increment\ndedupKey\tvarchar(64)\tNO\tUNI\tNULL\t\ntitre\ttext\tNO\t\tNULL\t\ncategorie\tvarchar(128)\tYES\t\tNULL\t\nniveau\tvarchar(128)\tYES\t\tNULL\t\nterritoire\tvarchar(255)\tYES\t\tNULL\t\nresume\ttext\tYES\t\tNULL\t\nsource\tvarchar(512)\tYES\t\tNULL\t\npassage\ttext\tYES\t\tNULL\t\nlien\ttext\tYES\t\tNULL\t\ntypeVeille\tenum('reglementaire','concurrentielle','technologique','informationnelle')\tNO\t\tNULL\t\ndatePublication\ttimestamp\tYES\t\tNULL\t\nimportedAt\ttimestamp\tNO\t\tCURRENT_TIMESTAMP\t\nterritoires\ttext\tYES\t\tNULL\t\niaRelevant\ttinyint(1)\tYES\t\tNULL\t\niaCategorie\tvarchar(128)\tYES\t\tNULL\t\niaClassifiedBy\tenum('ia','rules')\tYES\t\tNULL\t\niaReason\ttext\tYES\t\tNULL\t\niaResume\ttext\tYES\t\tNULL\t\n",
"stderr": "",
"execution_time_ms": 713
}

View File

@@ -0,0 +1,40 @@
{
"query": "\nSELECT \n titre,\n LEFT(resume, 200) AS resume_rss,\n CHAR_LENGTH(resume) AS longueur,\n typeVeille,\n iaRelevant,\n iaClassifiedBy,\n LEFT(iaReason, 200) AS iaReason,\n lien\nFROM veille_items\nWHERE CHAR_LENGTH(resume) < 150 OR resume IS NULL\nORDER BY CHAR_LENGTH(resume) ASC\nLIMIT 25;\n",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.63b125a8f9ca --database VepzDyqR8YkJNcqpZ729Bw --execute \nSELECT \n titre,\n LEFT(resume, 200) AS resume_rss,\n CHAR_LENGTH(resume) AS longueur,\n typeVeille,\n iaRelevant,\n iaClassifiedBy,\n LEFT(iaReason, 200) AS iaReason,\n lien\nFROM veille_items\nWHERE CHAR_LENGTH(resume) < 150 OR resume IS NULL\nORDER BY CHAR_LENGTH(resume) ASC\nLIMIT 25;\n",
"rows": [
{
"titre": "Décision n°2025.0056/DC/SCES du 1er avril 2025 du Président de la Haute Autorité de santé portant nomination dexperts-visiteurs pour la certification des établissements de santé",
"resume_rss": "NULL",
"longueur": "NULL",
"typeVeille": "reglementaire",
"iaRelevant": "1",
"iaClassifiedBy": "ia",
"iaReason": "Le texte concerne la certification des établissements de santé par la Haute Autorité de santé, relevant du système de soin et de l'organisation des établissements hospitaliers.",
"lien": "https://www.has-sante.fr/jcms/p_3861382/fr/decision-n2025-0056/dc/sces-du-1er-avril-2025-du-president-de-la-haute-autorite-de-sante-portant-nomination-d-experts-visiteurs-pour-la-certification-des-etablissements-de-sante"
},
{
"titre": "Avenant n°2 au PRS 20232028 - Publication de larrêté portant révision du PRS le 25 février 2026",
"resume_rss": "Le Projet Régional de Santé (PRS) 2023-2028 de lOccitanie actuellement en vigueur, vient pour la seconde fois dêtre révisé partiellement.",
"longueur": "139",
"typeVeille": "reglementaire",
"iaRelevant": "1",
"iaClassifiedBy": "ia",
"iaReason": "Le texte concerne la révision d'un Projet Régional de Santé (PRS) par arrêté, relevant de l'organisation du système de santé et de la réglementation.",
"lien": "https://www.occitanie.ars.sante.fr/avenant-ndeg2-au-prs-2023-2028-publication-de-larrete-portant-revision-du-prs-le-25-fevrier-2026-0"
},
{
"titre": "Que pensent les personnes handicapées de leur MDPH en 2025 ?",
"resume_rss": "Parmi les répondants, 7 personnes sur 10 sont très satisfaites ou satisfaites de leur maison départementale des personnes handicapées (MDPH).",
"longueur": "141",
"typeVeille": "informationnelle",
"iaRelevant": "1",
"iaClassifiedBy": "ia",
"iaReason": "Le texte aborde la satisfaction des personnes handicapées vis-à-vis de la MDPH, une structure dédiée à ce public.",
"lien": "https://www.cnsa.fr/actualites/que-pensent-les-personnes-handicapees-de-leur-mdph-en-2025"
}
],
"messages": [],
"stdout": "titre\tresume_rss\tlongueur\ttypeVeille\tiaRelevant\tiaClassifiedBy\tiaReason\tlien\nDécision n°2025.0056/DC/SCES du 1er avril 2025 du Président de la Haute Autorité de santé portant nomination dexperts-visiteurs pour la certification des établissements de santé\tNULL\tNULL\treglementaire\t1\tia\tLe texte concerne la certification des établissements de santé par la Haute Autorité de santé, relevant du système de soin et de l'organisation des établissements hospitaliers.\thttps://www.has-sante.fr/jcms/p_3861382/fr/decision-n2025-0056/dc/sces-du-1er-avril-2025-du-president-de-la-haute-autorite-de-sante-portant-nomination-d-experts-visiteurs-pour-la-certification-des-etablissements-de-sante\nAvenant n°2 au PRS 20232028 - Publication de larrêté portant révision du PRS le 25 février 2026\tLe Projet Régional de Santé (PRS) 2023-2028 de lOccitanie actuellement en vigueur, vient pour la seconde fois dêtre révisé partiellement.\t139\treglementaire\t1\tia\tLe texte concerne la révision d'un Projet Régional de Santé (PRS) par arrêté, relevant de l'organisation du système de santé et de la réglementation.\thttps://www.occitanie.ars.sante.fr/avenant-ndeg2-au-prs-2023-2028-publication-de-larrete-portant-revision-du-prs-le-25-fevrier-2026-0\nQue pensent les personnes handicapées de leur MDPH en 2025 ?\tParmi les répondants, 7 personnes sur 10 sont très satisfaites ou satisfaites de leur maison départementale des personnes handicapées (MDPH).\t141\tinformationnelle\t1\tia\tLe texte aborde la satisfaction des personnes handicapées vis-à-vis de la MDPH, une structure dédiée à ce public.\thttps://www.cnsa.fr/actualites/que-pensent-les-personnes-handicapees-de-leur-mdph-en-2025\n",
"stderr": "",
"execution_time_ms": 682
}

View File

@@ -0,0 +1,9 @@
{
"query": "\nSELECT \n titre,\n LEFT(resume, 250) AS resume_rss,\n CHAR_LENGTH(resume) AS longueur,\n typeVeille,\n iaRelevant,\n iaClassifiedBy,\n LEFT(iaReason, 250) AS iaReason\nFROM veille_items\nWHERE iaClassifiedBy = 'rules' OR iaRelevant = 0\nORDER BY importedAt DESC\nLIMIT 20;\n",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.63b125a8f9ca --database VepzDyqR8YkJNcqpZ729Bw --execute \nSELECT \n titre,\n LEFT(resume, 250) AS resume_rss,\n CHAR_LENGTH(resume) AS longueur,\n typeVeille,\n iaRelevant,\n iaClassifiedBy,\n LEFT(iaReason, 250) AS iaReason\nFROM veille_items\nWHERE iaClassifiedBy = 'rules' OR iaRelevant = 0\nORDER BY importedAt DESC\nLIMIT 20;\n",
"rows": [],
"messages": [],
"stdout": "",
"stderr": "",
"execution_time_ms": 590
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,8 @@
{
"query": "\nSELECT \n titre,\n LEFT(resume, 200) AS resume_court,\n CHAR_LENGTH(resume) AS longueur_resume,\n typeVeille,\n iaType,\n iaResume,\n lien\nFROM veille_items\nWHERE CHAR_LENGTH(resume) < 150\nORDER BY CHAR_LENGTH(resume) ASC\nLIMIT 20;\n",
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.63b125a8f9ca --database VepzDyqR8YkJNcqpZ729Bw --execute \nSELECT \n titre,\n LEFT(resume, 200) AS resume_court,\n CHAR_LENGTH(resume) AS longueur_resume,\n typeVeille,\n iaType,\n iaResume,\n lien\nFROM veille_items\nWHERE CHAR_LENGTH(resume) < 150\nORDER BY CHAR_LENGTH(resume) ASC\nLIMIT 20;\n",
"returncode": 1,
"logs": [
"ERROR 1054 (42S22) at line 2: Unknown column 'iatype' in 'field list'"
]
}

View File

@@ -1,4 +1,4 @@
{
"version": "7708bf54",
"timestamp": 1781683337686
"version": "6a49fe67",
"timestamp": 1781875679562
}

View File

@@ -32,26 +32,22 @@ export interface AiClassificationResult {
// ─── Prompt unique : pertinence + type de veille ─────────────────────────────
const PROMPT_CLASSIFICATION = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS) en France, spécialisé dans les secteurs : handicap, personnes âgées, protection de l'enfance, précarité/insertion, sanitaire.
const PROMPT_CLASSIFICATION = `Tu es un expert du secteur social, médico-social et sanitaire en France, spécialisé dans les associations gestionnaires d'établissements et services.
Ta mission est d'analyser un article et de déterminer :
1. S'il est DIRECTEMENT pertinent pour une association gestionnaire d'ESMS
2. Si oui, à quel type de veille il appartient
Analyse attentivement le texte fourni.
Détermine si le contenu est pertinent au regard du périmètre d'intervention de l'association, s'il a un impact professionnel direct ou indirect sur les activités, les services, la gestion, la réglementation, le financement, les professionnels, les usagers ou l'environnement stratégique de l'association.
Un article est pertinent UNIQUEMENT s'il traite explicitement de l'un des sujets suivants :
- Réglementation, lois, décrets, circulaires concernant les ESMS, le secteur social ou médico-social
- Financement, tarification, dotations des structures sociales et médico-sociales (CPOM, SERAFIN-PH, etc.)
- Politiques publiques ciblant les personnes handicapées, âgées, enfants en danger, personnes précaires
- Fonctionnement, organisation, ressources humaines des ESMS
- Droits et accompagnement des usagers des ESMS
- Institutions directement liées : ARS, MDPH, ASE, conseils départementaux (action sociale), CAF, CNSA
- Santé publique avec impact direct sur les ESMS ou leurs usagers
Un article est pertinent UNIQUEMENT s'il traite explicitement d'un sujet en lien avec l'un des 5 secteurs d'activité de l'association :
1. Handicap (enfant et adulte) : services, dispositifs politiques et financement liés au handicap ; ESAT, IME, ITEP, SESSAD, MAS, FAM, SAVS, SAMSAH, MDPH, RQTH, inclusion scolaire, emploi des personnes handicapées
2. Précarité / Logement : les problématiques et situations de précarité, d'exclusion, de vulnérabilité, d'accès aux droits ou d'insertion ; CHRS, SIAO, hébergement d'urgence, insertion par le logement, RSA, sans-abrisme, expulsions
3. Protection de l'enfance : services de protection de l'enfance ; ASE, MECS, famille d'accueil, pupilles de l'État, prévention spécialisée, mineurs non accompagnés
4. Personnes âgées : l'accompagnement des personnes âgées, les soins associés et la perte d'autonomie ; EHPAD, SSIAD, résidences autonomie, APA, dépendance, plan grand âge
5. Sanitaire : la gestion sanitaire et les questions de santé ; SMR (Soins de Suite et de Réadaptation), HAD, cliniques de réadaptation, parcours de soins post-aigus
Un article est NON PERTINENT si :
- Il traite de politique générale, économie, international, environnement, sport, culture, agriculture, pêche, tourisme, immobilier, technologie grand public, rénovation énergétique, etc.
- Il mentionne des personnes vulnérables de façon anecdotique sans lien avec les ESMS
- Il concerne la santé uniquement sous l'angle hospitalier ou médical sans lien avec le médico-social
- Le lien avec le secteur médico-social est vague, indirect ou nécessite plusieurs degrés de déduction
- Il mentionne des personnes vulnérables de façon anecdotique sans lien direct avec les 5 secteurs ci-dessus
- Le lien avec les secteurs d'activité de l'association est vague, indirect ou nécessite plusieurs degrés de déduction
Sois STRICT : en cas de doute, réponds pertinent: false.
@@ -103,16 +99,22 @@ function parseJsonSafe<T>(text: string): T | null {
* Détermine simultanément la pertinence et le type de veille.
* Retourne toujours un résultat (fallback sur rules en cas d'erreur).
*
* @param titre Titre de l'article
* @param resume Résumé / description de l'article
* @param fallbackFn Fonction de fallback appelée si l'IA échoue
* @param titre Titre de l'article
* @param resume Résumé / description de l'article
* @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(
titre: string,
resume: string,
fallbackFn: () => { typeVeille: AiTypeVeille }
fallbackFn: () => { typeVeille: AiTypeVeille },
contenuPage?: string | null
): 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 {
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";
import { eq, sql } from "drizzle-orm";
import { classifyArticle, generateSummary } from "./aiClassifier";
import { fetchArticleFirstParagraph } from "./articleFetcher";
// ─── Types internes ───────────────────────────────────────────────────────────
@@ -351,9 +352,14 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
const dedupKey = dedupHash(normalizedTitle + "|" + (feed.feedType ?? ""));
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);
// 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(
title,
description,
@@ -363,7 +369,8 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
typeVeille: (matchedRule?.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
"reglementaire" | "concurrentielle" | "technologique" | "informationnelle",
};
}
},
contenuPage
);
const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
@@ -420,12 +427,18 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
// ── Classification IA pour les AAP ───────────────────────────────────
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(
title,
description,
() => ({
typeVeille: "informationnelle" as const,
})
}),
contenuPageAap
);
const categorie = (feed.defaultCategorieAap ?? "Autre") as

12
todo.md
View File

@@ -150,3 +150,15 @@
- [x] VeilleDashboard.tsx : supprimer tout filtre ou affichage lié à iaCategorie
- [x] Tester visuellement en sandbox
- [x] Checkpoint
## Amélioration prompt IA — 5 secteurs d'activité
- [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] 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