Files
veille-reglementaire/server/aiClassifier.ts

323 lines
15 KiB
TypeScript

/**
* Classification IA des articles RSS en deux étapes :
*
* Étape 1 — Filtre de pertinence :
* Détermine si l'article est pertinent pour le secteur médico-social
* (handicap, personnes âgées, protection de l'enfance, précarité, sanitaire).
*
* Étape 2 — Classification par catégorie :
* Si l'article est pertinent, le classe dans l'une des catégories du secteur.
*
* En cas d'erreur LLM (timeout, quota, JSON malformé), le module retourne
* classifiedBy = "rules" pour signaler que le fallback doit être utilisé.
*/
import { invokeLLM } from "./_core/llm";
// ─── Types ────────────────────────────────────────────────────────────────────
export type AiCategorie =
| "Handicap"
| "PA"
| "Enfance"
| "Précarité"
| "Sanitaire"
| "Autre";
export type AiTypeVeille =
| "reglementaire"
| "concurrentielle"
| "technologique"
| "generale";
export interface AiClassificationResult {
/** L'article est-il pertinent pour le secteur médico-social ? */
relevant: boolean;
/** Catégorie principale déduite par l'IA */
categorie: AiCategorie;
/** Type de veille déduit par l'IA (uniquement pour les articles de veille) */
typeVeille?: AiTypeVeille;
/** 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 1 : Filtre de pertinence ─────────────────────────────────────────
const PROMPT_PERTINENCE = `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.
Ta mission est de déterminer si un article de presse ou une publication institutionnelle est DIRECTEMENT pertinent pour une association gestionnaire d'ESMS.
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 NON PERTINENT si :
- Il traite de politique générale, économie, international, environnement, sport, culture, agriculture, pêche, tourisme, immobilier, technologie grand public, 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
Sois STRICT : en cas de doute, réponds pertinent: false.
Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, sans balises markdown :
{"pertinent": true/false, "raison": "explication courte en une phrase"}`;
// ─── Prompt 2 : Classification par catégorie ─────────────────────────────────
const PROMPT_CLASSIFICATION = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS) évoluant dans les secteurs suivants: handicap, personnes âgées, protection de l'enfance, précarité, sanitaire. Analyse le texte fourni et classe-le dans un seul secteur principal en te basant uniquement sur le public concerné, la structure mentionnée, l'objectif principal et le cadre institutionnel éventuel (ARS, Département, Justice, etc.).
Handicap : choisir cette catégorie si le public principal est une personne en situation de handicap (moteur, psychique, intellectuel, sensoriel, TSA, polyhandicap, etc.). Inclut les structures et dispositifs tels que IME, ITEP, ESAT, MAS, FAM, SAVS, SAMSAH, SESSAD, MDPH, PCH, SERAFIN-PH, inclusion et compensation du handicap. Même si des soins sont évoqués, rester dans cette catégorie si la structure principale est médico-sociale liée au handicap.
Personnes âgées : choisir cette catégorie si le sujet principal concerne le vieillissement ou la perte d'autonomie. Inclut EHPAD, SSIAD, SPASAD, résidences autonomie, GIR, APA, maintien à domicile, dépendance, maladies neurodégénératives. Si le sujet porte principalement sur l'hôpital ou l'organisation des soins hospitaliers, choisir sanitaire.
Protection de l'enfance : choisir cette catégorie si le texte concerne des mineurs en danger ou protégés. Inclut ASE, MECS, AEMO, placement familial, protection judiciaire, MNA, mesures administratives ou judiciaires de protection. Si la situation concerne la pauvreté familiale sans mesure de protection, choisir précarité/insertion.
Précarité / Insertion : choisir cette catégorie si le sujet concerne l'exclusion sociale, l'hébergement, l'accès aux droits ou l'insertion professionnelle. Inclut CHRS, hébergement d'urgence, RSA, insertion sociale ou professionnelle, logement accompagné, accompagnement des publics migrants. Si une mesure judiciaire protège un mineur, choisir protection de l'enfance.
Sanitaire : choisir cette catégorie si le sujet relève principalement du soin médical, de l'hôpital ou de l'organisation du système de santé. Inclut hôpital, parcours de soins, CPTS, psychiatrie hospitalière, santé publique, organisation des soins par l'ARS. Si l'article concerne un établissement médico-social avec coordination médicale, choisir le secteur médico-social correspondant plutôt que sanitaire.
Toujours identifier en priorité le public principal, puis l'objectif central (soin médical, accompagnement social, protection, insertion), puis la structure dominante.
Si aucune catégorie ne correspond clairement, ou si le texte ne concerne pas le secteur médico-social, répondre "Autre".
IMPORTANT : Si le texte concerne la pêche, l'agriculture, le sport, la culture, l'environnement, l'économie générale, la politique internationale, ou tout autre domaine sans lien direct avec les ESMS, réponds obligatoirement {"categorie": "Autre", ...}.
Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, sans balises markdown :
{"categorie": "Handicap"|"PA"|"Enfance"|"Précarité"|"Sanitaire"|"Autre", "typeVeille": "reglementaire"|"concurrentielle"|"technologique"|"generale", "raison": "explication courte en une phrase"}
Pour typeVeille :
- reglementaire : textes de loi, décrets, circulaires, obligations légales, réformes institutionnelles
- concurrentielle : actualités d'autres associations, appels d'offres, marchés, positionnement sectoriel
- technologique : innovations numériques, outils, systèmes d'information, nouvelles pratiques
- generale : tout autre sujet pertinent sans caractère réglementaire, concurrentiel ou technologique fort`;
// ─── Utilitaire : parse JSON robuste ─────────────────────────────────────────
function parseJsonSafe<T>(text: string): T | null {
try {
// Nettoyer les éventuelles balises markdown ```json ... ```
const cleaned = text
.replace(/^```json\s*/i, "")
.replace(/^```\s*/i, "")
.replace(/```\s*$/i, "")
.trim();
return JSON.parse(cleaned) as T;
} catch {
return null;
}
}
// ─── Étape 1 : Filtre de pertinence ──────────────────────────────────────────
async function checkRelevance(
titre: string,
resume: string
): Promise<{ pertinent: boolean; raison: string } | null> {
const userContent = `Titre : ${titre}\n\nRésumé : ${resume}`;
try {
const response = await invokeLLM({
messages: [
{ role: "system", content: PROMPT_PERTINENCE },
{ role: "user", content: userContent },
],
response_format: {
type: "json_schema",
json_schema: {
name: "pertinence_result",
strict: true,
schema: {
type: "object",
properties: {
pertinent: { type: "boolean" },
raison: { type: "string" },
},
required: ["pertinent", "raison"],
additionalProperties: false,
},
},
},
});
const rawContent = response?.choices?.[0]?.message?.content;
if (!rawContent) return null;
const content = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
const parsed = parseJsonSafe<{ pertinent: boolean; raison: string }>(content);
return parsed;
} catch (e) {
console.error("[AI Classifier] Erreur étape 1 (pertinence):", (e as Error).message);
return null;
}
}
// ─── Étape 2 : Classification par catégorie ───────────────────────────────────
async function classifyCategory(
titre: string,
resume: string
): Promise<{ categorie: AiCategorie; typeVeille: AiTypeVeille; raison: string } | null> {
const userContent = `Titre : ${titre}\n\nRésumé : ${resume}`;
try {
const response = await invokeLLM({
messages: [
{ role: "system", content: PROMPT_CLASSIFICATION },
{ role: "user", content: userContent },
],
response_format: {
type: "json_schema",
json_schema: {
name: "classification_result",
strict: true,
schema: {
type: "object",
properties: {
categorie: {
type: "string",
enum: ["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"],
},
typeVeille: {
type: "string",
enum: ["reglementaire", "concurrentielle", "technologique", "generale"],
},
raison: { type: "string" },
},
required: ["categorie", "typeVeille", "raison"],
additionalProperties: false,
},
},
},
});
const rawContent = response?.choices?.[0]?.message?.content;
if (!rawContent) return null;
const content = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
const parsed = parseJsonSafe<{
categorie: AiCategorie;
typeVeille: AiTypeVeille;
raison: string;
}>(content);
return parsed;
} catch (e) {
console.error("[AI Classifier] Erreur étape 2 (classification):", (e as Error).message);
return null;
}
}
// ─── Prompt 3 : Génération de résumé IA ──────────────────────────────────────────
const PROMPT_RESUME = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS).
Tu dois rédiger un résumé clair et professionnel de l'article fourni, destiné aux professionnels du secteur médico-social.
Consignes :
- Rédige 3 à 5 phrases en français, en langage clair et accessible
- Mets en avant les points clés : qui est concerné, quelle mesure ou information, quel impact pour les structures
- Reste factuel et objectif, sans jugement de valeur
- N'utilise pas de listes à puces, écris en prose
- Ne commence pas par "Cet article" ou "Le texte"
- Adapte le vocabulaire au secteur médico-social (ESMS, ARS, MDPH, etc.)
Réponds UNIQUEMENT avec le texte du résumé, sans introduction ni conclusion.`;
/**
* Génère un résumé IA de 3-5 phrases pour un article pertinent.
* Retourne null en cas d'échec (le résumé brut RSS sera utilisé à la place).
*/
export async function generateSummary(
titre: string,
resume: string
): Promise<string | null> {
const userContent = `Titre : ${titre}\n\nContenu : ${resume}`;
try {
const response = await invokeLLM({
messages: [
{ role: "system", content: PROMPT_RESUME },
{ role: "user", content: userContent },
],
});
const rawContent = response?.choices?.[0]?.message?.content;
if (!rawContent) return null;
const text = typeof rawContent === "string" ? rawContent.trim() : null;
return text && text.length > 20 ? text : null;
} catch (e) {
console.error("[AI Classifier] Erreur génération résumé:", (e as Error).message);
return null;
}
}
// ─── Point d'entrée principal ─────────────────────────────────────────────────
/**
* Classifie un article en deux étapes via l'IA.
* 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 (classification par règles) appelée si l'IA échoue
*/
export async function classifyArticle(
titre: string,
resume: string,
fallbackFn: () => { categorie: AiCategorie; typeVeille: AiTypeVeille }
): Promise<AiClassificationResult> {
// ── Étape 1 : Pertinence ──────────────────────────────────────────────────
const relevanceResult = await checkRelevance(titre, resume);
if (!relevanceResult) {
// Erreur LLM → fallback
const fb = fallbackFn();
return {
relevant: true, // on suppose pertinent par défaut
categorie: fb.categorie,
typeVeille: fb.typeVeille,
reason: "Classification par règles (erreur LLM étape 1)",
classifiedBy: "rules",
};
}
if (!relevanceResult.pertinent) {
// Article non pertinent → on l'insère quand même avec categorie "Autre"
return {
relevant: false,
categorie: "Autre",
typeVeille: "generale",
reason: relevanceResult.raison,
classifiedBy: "ia",
};
}
// ── Étape 2 : Classification ──────────────────────────────────────────────
const classResult = await classifyCategory(titre, resume);
if (!classResult) {
// Erreur LLM → fallback
const fb = fallbackFn();
return {
relevant: true,
categorie: fb.categorie,
typeVeille: fb.typeVeille,
reason: `Pertinent (${relevanceResult.raison}) — classification par règles (erreur LLM étape 2)`,
classifiedBy: "rules",
};
}
return {
relevant: true,
categorie: classResult.categorie,
typeVeille: classResult.typeVeille,
reason: classResult.raison,
classifiedBy: "ia",
};
}