From 2d0de2a30d475a9063cefed04de91084b73bee32 Mon Sep 17 00:00:00 2001 From: Manus Date: Mon, 6 Jul 2026 10:24:40 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20prompt=20IA=20d=C3=A9di=C3=A9=20AAP=20a?= =?UTF-8?q?vec=20classification=20par=20secteur=20(Handicap/PA/Enfance/Pr?= =?UTF-8?q?=C3=A9carit=C3=A9/Sanitaire)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/aiClassifier.ts | 149 +++++++++++++++++++++++++++++++++++------ server/rssEngine.ts | 11 ++- 2 files changed, 131 insertions(+), 29 deletions(-) diff --git a/server/aiClassifier.ts b/server/aiClassifier.ts index 1b2f628..5a478f8 100644 --- a/server/aiClassifier.ts +++ b/server/aiClassifier.ts @@ -19,18 +19,28 @@ export type AiTypeVeille = | "technologique" | "informationnelle"; +export type AiCategorieAap = + | "Handicap" + | "Précarité" + | "Enfance" + | "PA" + | "Sanitaire" + | "Autre"; + export interface AiClassificationResult { /** L'article est-il pertinent pour le secteur médico-social ? */ relevant: boolean; /** Type de veille déduit par l'IA (null si non pertinent) */ typeVeille: AiTypeVeille | null; + /** Catégorie AAP déduite par l'IA (null si non AAP ou non pertinent) */ + categorieAap: AiCategorieAap | null; /** Explication courte de la décision */ reason: string; /** Indique si la classification a été faite par l'IA ou par les règles (fallback) */ classifiedBy: "ia" | "rules"; } -// ─── Prompt unique : pertinence + type de veille ───────────────────────────── +// ─── Prompt veille stratégique : pertinence + type de veille ───────────────── const PROMPT_CLASSIFICATION = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS) en France. 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. @@ -67,6 +77,46 @@ Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, sans balises m Si pertinent est false, typeVeille doit être null.`; +// ─── Prompt AAP : pertinence + catégorie secteur ───────────────────────────── + +const PROMPT_CLASSIFICATION_AAP = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS) en France. +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 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 s'il porte principalement sur l'un des sujets suivants, même s'il mentionne des termes administratifs ou régionaux : +- Sylviculture, forêts, gestion forestière, bois, chasse, pêche +- Agriculture, élevage, viticulture, pêche maritime, filières agroalimentaires +- Urbanisme, construction, permis de construire, plans locaux d'urbanisme (PLU), logement neuf (hors hébergement social) +- Transports, mobilité, infrastructures routières, ferroviaires ou aéroportuaires +- Environnement, biodiversité, eau, énergie, transition écologique (sauf si lien direct avec les ESMS) +- Économie générale, fiscalité des entreprises, commerce, industrie, artisanat +- Défense nationale, sécurité intérieure, police, justice (sauf protection de l'enfance) +- Sport, culture, tourisme (sauf si lien direct avec des usagers ESMS) +- Enseignement supérieur, recherche (sauf si lien direct avec le secteur médico-social) +- Nominations administratives sans lien avec les secteurs ci-dessus + +En cas de doute, réponds pertinent: false — il vaut mieux rejeter un article ambigu que d'en laisser passer un hors périmètre. + +Si l'article est pertinent, classe-le dans l'une des 5 catégories suivantes (utilise exactement ces valeurs) : +- "Handicap" : lien avec le secteur handicap (enfant ou adulte) +- "Précarité" : lien avec la précarité, l'exclusion, le logement social ou l'insertion +- "Enfance" : lien avec la protection de l'enfance +- "PA" : lien avec les personnes âgées ou la perte d'autonomie +- "Sanitaire" : lien avec la santé, les soins ou le sanitaire + +Si l'article est pertinent mais ne correspond clairement à aucune des 5 catégories, utilise "Autre". + +Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, sans balises markdown : +{"pertinent": true/false, "categorie": "Handicap"|"Précarité"|"Enfance"|"PA"|"Sanitaire"|"Autre"|null, "raison": "explication courte en une phrase"} + +Si pertinent est false, categorie doit être null.`; + // ─── Prompt résumé IA ───────────────────────────────────────────────────────── const PROMPT_RESUME = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS). @@ -100,14 +150,8 @@ function parseJsonSafe(text: string): T | null { // ─── Classification principale ──────────────────────────────────────────────── /** - * Classifie un article en une seule étape via l'IA. - * Détermine simultanément la pertinence et le type de veille. + * Classifie un article de veille stratégique : pertinence + 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 contenuPage Premiers paragraphes de l'article complet (optionnel, améliore la précision) */ export async function classifyArticle( titre: string, @@ -115,7 +159,6 @@ export async function classifyArticle( fallbackFn: () => { typeVeille: AiTypeVeille }, contenuPage?: string | null ): Promise { - // 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}` : ""; @@ -138,10 +181,7 @@ export async function classifyArticle( pertinent: { type: "boolean" }, typeVeille: { anyOf: [ - { - type: "string", - enum: ["reglementaire", "concurrentielle", "technologique", "informationnelle"], - }, + { type: "string", enum: ["reglementaire", "concurrentielle", "technologique", "informationnelle"] }, { type: "null" }, ], }, @@ -156,29 +196,94 @@ export async function classifyArticle( const rawContent = response?.choices?.[0]?.message?.content; if (!rawContent) throw new Error("Réponse LLM vide"); - const content = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent); - const parsed = parseJsonSafe<{ - pertinent: boolean; - typeVeille: AiTypeVeille | null; - raison: string; - }>(content); - + const parsed = parseJsonSafe<{ pertinent: boolean; typeVeille: AiTypeVeille | null; raison: string }>(content); if (!parsed) throw new Error("JSON LLM invalide"); return { relevant: parsed.pertinent, typeVeille: parsed.pertinent ? (parsed.typeVeille ?? "informationnelle") : null, + categorieAap: null, reason: parsed.raison, classifiedBy: "ia", }; } catch (e) { - console.error("[AI Classifier] Erreur classification:", (e as Error).message); - // Fallback sur les règles + console.error("[AI Classifier] Erreur classification veille:", (e as Error).message); const fb = fallbackFn(); return { relevant: true, typeVeille: fb.typeVeille, + categorieAap: null, + reason: "Classification par règles (erreur LLM)", + classifiedBy: "rules", + }; + } +} + +/** + * Classifie un appel à projet : pertinence + catégorie secteur (Handicap/PA/Enfance/Précarité/Sanitaire). + * Retourne toujours un résultat (fallback sur rules en cas d'erreur). + */ +export async function classifyAap( + titre: string, + resume: string, + fallbackCategorie: AiCategorieAap, + contenuPage?: string | null +): Promise { + 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({ + messages: [ + { role: "system", content: PROMPT_CLASSIFICATION_AAP }, + { role: "user", content: userContent }, + ], + response_format: { + type: "json_schema", + json_schema: { + name: "classification_aap_result", + strict: true, + schema: { + type: "object", + properties: { + pertinent: { type: "boolean" }, + categorie: { + anyOf: [ + { type: "string", enum: ["Handicap", "Précarité", "Enfance", "PA", "Sanitaire", "Autre"] }, + { type: "null" }, + ], + }, + raison: { type: "string" }, + }, + required: ["pertinent", "categorie", "raison"], + additionalProperties: false, + }, + }, + }, + }); + + const rawContent = response?.choices?.[0]?.message?.content; + if (!rawContent) throw new Error("Réponse LLM vide"); + const content = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent); + const parsed = parseJsonSafe<{ pertinent: boolean; categorie: AiCategorieAap | null; raison: string }>(content); + if (!parsed) throw new Error("JSON LLM invalide"); + + return { + relevant: parsed.pertinent, + typeVeille: null, + categorieAap: parsed.pertinent ? (parsed.categorie ?? "Autre") : null, + reason: parsed.raison, + classifiedBy: "ia", + }; + } catch (e) { + console.error("[AI Classifier] Erreur classification AAP:", (e as Error).message); + return { + relevant: true, + typeVeille: null, + categorieAap: fallbackCategorie, reason: "Classification par règles (erreur LLM)", classifiedBy: "rules", }; diff --git a/server/rssEngine.ts b/server/rssEngine.ts index 7bda3ea..2ea5632 100644 --- a/server/rssEngine.ts +++ b/server/rssEngine.ts @@ -23,7 +23,7 @@ import { type RssFeed, } from "../drizzle/schema"; import { eq, sql } from "drizzle-orm"; -import { classifyArticle, generateSummary } from "./aiClassifier"; +import { classifyArticle, classifyAap, generateSummary } from "./aiClassifier"; import { fetchArticleFirstParagraph } from "./articleFetcher"; // ─── Types internes ─────────────────────────────────────────────────────────── @@ -630,16 +630,13 @@ async function processFeed(feed: RssFeed): Promise { ? await fetchArticleFirstParagraph(link) : null; - const aiResult = await classifyArticle( + const aiResult = await classifyAap( title, description, - () => ({ - typeVeille: "informationnelle" as const, - }), + (feed.defaultCategorieAap ?? "Autre") as "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre", contenuPageAap ); - - const categorie = (feed.defaultCategorieAap ?? "Autre") as + const categorie = (aiResult.categorieAap ?? feed.defaultCategorieAap ?? "Autre") as "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre"; // ── Filtre de pertinence : ne pas insérer les AAP non pertinents ─────────