feat: prompt IA dédié AAP avec classification par secteur (Handicap/PA/Enfance/Précarité/Sanitaire)
This commit is contained in:
@@ -19,18 +19,28 @@ export type AiTypeVeille =
|
|||||||
| "technologique"
|
| "technologique"
|
||||||
| "informationnelle";
|
| "informationnelle";
|
||||||
|
|
||||||
|
export type AiCategorieAap =
|
||||||
|
| "Handicap"
|
||||||
|
| "Précarité"
|
||||||
|
| "Enfance"
|
||||||
|
| "PA"
|
||||||
|
| "Sanitaire"
|
||||||
|
| "Autre";
|
||||||
|
|
||||||
export interface AiClassificationResult {
|
export interface AiClassificationResult {
|
||||||
/** L'article est-il pertinent pour le secteur médico-social ? */
|
/** L'article est-il pertinent pour le secteur médico-social ? */
|
||||||
relevant: boolean;
|
relevant: boolean;
|
||||||
/** Type de veille déduit par l'IA (null si non pertinent) */
|
/** Type de veille déduit par l'IA (null si non pertinent) */
|
||||||
typeVeille: AiTypeVeille | null;
|
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 */
|
/** Explication courte de la décision */
|
||||||
reason: string;
|
reason: string;
|
||||||
/** Indique si la classification a été faite par l'IA ou par les règles (fallback) */
|
/** Indique si la classification a été faite par l'IA ou par les règles (fallback) */
|
||||||
classifiedBy: "ia" | "rules";
|
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.
|
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.
|
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.`;
|
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 ─────────────────────────────────────────────────────────
|
// ─── Prompt résumé IA ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const PROMPT_RESUME = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS).
|
const PROMPT_RESUME = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS).
|
||||||
@@ -100,14 +150,8 @@ function parseJsonSafe<T>(text: string): T | null {
|
|||||||
// ─── Classification principale ────────────────────────────────────────────────
|
// ─── Classification principale ────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Classifie un article en une seule étape via l'IA.
|
* Classifie un article de veille stratégique : pertinence + 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 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(
|
export async function classifyArticle(
|
||||||
titre: string,
|
titre: string,
|
||||||
@@ -115,7 +159,6 @@ export async function classifyArticle(
|
|||||||
fallbackFn: () => { typeVeille: AiTypeVeille },
|
fallbackFn: () => { typeVeille: AiTypeVeille },
|
||||||
contenuPage?: string | null
|
contenuPage?: string | null
|
||||||
): Promise<AiClassificationResult> {
|
): Promise<AiClassificationResult> {
|
||||||
// Construire le contenu utilisateur en enrichissant avec le contenu de la page si disponible
|
|
||||||
const contenuSection = contenuPage
|
const contenuSection = contenuPage
|
||||||
? `\n\nExtrait de l'article (premiers paragraphes) : ${contenuPage}`
|
? `\n\nExtrait de l'article (premiers paragraphes) : ${contenuPage}`
|
||||||
: "";
|
: "";
|
||||||
@@ -138,10 +181,7 @@ export async function classifyArticle(
|
|||||||
pertinent: { type: "boolean" },
|
pertinent: { type: "boolean" },
|
||||||
typeVeille: {
|
typeVeille: {
|
||||||
anyOf: [
|
anyOf: [
|
||||||
{
|
{ type: "string", enum: ["reglementaire", "concurrentielle", "technologique", "informationnelle"] },
|
||||||
type: "string",
|
|
||||||
enum: ["reglementaire", "concurrentielle", "technologique", "informationnelle"],
|
|
||||||
},
|
|
||||||
{ type: "null" },
|
{ type: "null" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -156,29 +196,94 @@ export async function classifyArticle(
|
|||||||
|
|
||||||
const rawContent = response?.choices?.[0]?.message?.content;
|
const rawContent = response?.choices?.[0]?.message?.content;
|
||||||
if (!rawContent) throw new Error("Réponse LLM vide");
|
if (!rawContent) throw new Error("Réponse LLM vide");
|
||||||
|
|
||||||
const content = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
|
const content = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
|
||||||
const parsed = parseJsonSafe<{
|
const parsed = parseJsonSafe<{ pertinent: boolean; typeVeille: AiTypeVeille | null; raison: string }>(content);
|
||||||
pertinent: boolean;
|
|
||||||
typeVeille: AiTypeVeille | null;
|
|
||||||
raison: string;
|
|
||||||
}>(content);
|
|
||||||
|
|
||||||
if (!parsed) throw new Error("JSON LLM invalide");
|
if (!parsed) throw new Error("JSON LLM invalide");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
relevant: parsed.pertinent,
|
relevant: parsed.pertinent,
|
||||||
typeVeille: parsed.pertinent ? (parsed.typeVeille ?? "informationnelle") : null,
|
typeVeille: parsed.pertinent ? (parsed.typeVeille ?? "informationnelle") : null,
|
||||||
|
categorieAap: null,
|
||||||
reason: parsed.raison,
|
reason: parsed.raison,
|
||||||
classifiedBy: "ia",
|
classifiedBy: "ia",
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[AI Classifier] Erreur classification:", (e as Error).message);
|
console.error("[AI Classifier] Erreur classification veille:", (e as Error).message);
|
||||||
// Fallback sur les règles
|
|
||||||
const fb = fallbackFn();
|
const fb = fallbackFn();
|
||||||
return {
|
return {
|
||||||
relevant: true,
|
relevant: true,
|
||||||
typeVeille: fb.typeVeille,
|
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<AiClassificationResult> {
|
||||||
|
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)",
|
reason: "Classification par règles (erreur LLM)",
|
||||||
classifiedBy: "rules",
|
classifiedBy: "rules",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
type RssFeed,
|
type RssFeed,
|
||||||
} 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, classifyAap, generateSummary } from "./aiClassifier";
|
||||||
import { fetchArticleFirstParagraph } from "./articleFetcher";
|
import { fetchArticleFirstParagraph } from "./articleFetcher";
|
||||||
|
|
||||||
// ─── Types internes ───────────────────────────────────────────────────────────
|
// ─── Types internes ───────────────────────────────────────────────────────────
|
||||||
@@ -630,16 +630,13 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
|||||||
? await fetchArticleFirstParagraph(link)
|
? await fetchArticleFirstParagraph(link)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const aiResult = await classifyArticle(
|
const aiResult = await classifyAap(
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
() => ({
|
(feed.defaultCategorieAap ?? "Autre") as "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre",
|
||||||
typeVeille: "informationnelle" as const,
|
|
||||||
}),
|
|
||||||
contenuPageAap
|
contenuPageAap
|
||||||
);
|
);
|
||||||
|
const categorie = (aiResult.categorieAap ?? feed.defaultCategorieAap ?? "Autre") as
|
||||||
const categorie = (feed.defaultCategorieAap ?? "Autre") as
|
|
||||||
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
|
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
|
||||||
|
|
||||||
// ── Filtre de pertinence : ne pas insérer les AAP non pertinents ─────────
|
// ── Filtre de pertinence : ne pas insérer les AAP non pertinents ─────────
|
||||||
|
|||||||
Reference in New Issue
Block a user