Files
veille-reglementaire/server/aiClassifier.ts

323 lines
16 KiB
TypeScript

/**
* Classification IA des articles RSS en une seule étape :
*
* Le LLM détermine simultanément :
* 1. Si l'article est pertinent pour le secteur médico-social
* 2. Le type de veille (réglementaire, concurrentielle, technologique, informationnelle)
*
* 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 AiTypeVeille =
| "reglementaire"
| "concurrentielle"
| "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";
/** Cause technique du fallback, réservée au journal d'administration. */
technicalError: string | null;
}
function getTechnicalErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message.slice(0, 1000);
}
// ─── 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.
Pose-toi cette unique question : "Un directeur d'ESMS ou un cadre de direction d'une association médico-sociale (Itinova) devrait-il lire cet article pour faire son travail ?"
Un article est pertinent s'il concerne directement ou indirectement :
• 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
• 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, expulsion
• 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
• 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
• 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
• Réglementation applicable aux ESMS (lois, décrets, circulaires, instructions)
• Financement des ESMS (dotations, tarification, appels à projets, CPOM)
• Pratiques professionnelles ou droits des usagers accompagnés (personnes handicapées, personnes âgées, enfants protégés, personnes en précarité, patients en SSR/HAD)
• Gestion, ressources humaines, numérique ou stratégie d'une association gestionnaire d'ESMS
• Environnement concurrentiel ou partenarial du secteur médico-social
Un article est NON PERTINENT si, même en le lisant en entier, un directeur d'ESMS n'en tirerait aucune information utile à son activité professionnelle — qu'il porte sur l'environnement, l'agriculture, les transports, la politique générale, le sport, ou tout autre sujet sans lien opérationnel avec les ESMS.
En cas de doute, réponds pertinent: false.
Si l'article est pertinent, classe-le dans l'un des types de veille suivants :
- "reglementaire" : textes de loi, décrets, circulaires, obligations légales, réformes institutionnelles, instructions ministérielles
- "concurrentielle" : actualités d'autres associations ou structures, appels d'offres, marchés, positionnement sectoriel, benchmarks
- "technologique" : innovations numériques, outils, systèmes d'information, nouvelles pratiques professionnelles, recherche appliquée
- "informationnelle" : informations générales pertinentes pour le secteur sans caractère réglementaire, concurrentiel ou technologique fort (rapports, études, événements sectoriels, nominations, etc.)
Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, sans balises markdown :
{"pertinent": true/false, "typeVeille": "reglementaire"|"concurrentielle"|"technologique"|"informationnelle"|null, "raison": "explication courte en une phrase"}
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.
Pose-toi cette unique question : "Un directeur d'ESMS ou un cadre de direction d'une association médico-sociale (Itinova) devrait-il répondre à cet appel à projets ou à cet appel à candidatures ?"
Un appel à projets est pertinent s'il vise à financer ou développer des actions concernant directement ou indirectement :
• Handicap (enfant et adulte) : services, dispositifs et financement liés au handicap ; ESAT, IME, ITEP, SESSAD, MAS, FAM, SAVS, SAMSAH, MDPH, RQTH, inclusion scolaire, emploi des personnes handicapées
• Précarité / Logement : les situations de précarité, d'exclusion, de vulnérabilité, d'accès aux droits, aux soins ou à l'insertion ; CHRS, SIAO, hébergement d'urgence, insertion par le logement, RSA, sans-abrisme, expulsion, personnes isolées ou en situation de vulnérabilité sociale
• Protection de l'enfance : ASE, MECS, famille d'accueil, pupilles de l'État, prévention spécialisée, mineurs non accompagnés
• Personnes âgées : accompagnement des personnes âgées, perte d'autonomie, soins associés ; EHPAD, SSIAD, résidences autonomie, APA, dépendance, plan grand âge
• Sanitaire : santé, soins, réadaptation ; SMR, HAD, cliniques de réadaptation, parcours de soins post-aigus
• Actions transversales bénéficiant aux usagers des ESMS : accès aux soins, inclusion numérique, lutte contre l'isolement, innovation sociale, bien-être des professionnels du secteur
Un appel à projets est NON PERTINENT si, même en le lisant en entier, un directeur d'ESMS ne pourrait pas y répondre ni en bénéficier — qu'il porte sur l'agriculture, l'environnement, les transports, la recherche fondamentale, le sport de haut niveau, ou tout autre domaine sans lien opérationnel avec les ESMS ou leurs usagers.
En cas de doute, réponds pertinent: false.
Si l'appel à projets est pertinent, identifie le secteur principal concerné parmi les 5 suivants (utilise exactement ces valeurs) :
- "Handicap" : bénéficiaires principaux = personnes en situation de handicap (enfant ou adulte)
- "Précarité" : bénéficiaires principaux = personnes en situation de précarité, d'exclusion, de vulnérabilité sociale ou d'isolement
- "Enfance" : bénéficiaires principaux = enfants et jeunes relevant de la protection de l'enfance
- "PA" : bénéficiaires principaux = personnes âgées ou en perte d'autonomie
- "Sanitaire" : bénéficiaires principaux = patients ou professionnels du sanitaire
Si l'appel à projets est pertinent mais concerne plusieurs secteurs à égalité, choisis celui qui représente le plus grand volume d'activité d'Itinova. Si aucune catégorie ne convient clairement, 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).
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.`;
// ─── Utilitaire : parse JSON robuste ─────────────────────────────────────────
function parseJsonSafe<T>(text: string): T | null {
try {
const cleaned = text
.replace(/^```json\s*/i, "")
.replace(/^```\s*/i, "")
.replace(/```\s*$/i, "")
.trim();
return JSON.parse(cleaned) as T;
} catch {
return null;
}
}
// ─── Classification principale ────────────────────────────────────────────────
/**
* Classifie un article de veille stratégique : pertinence + type de veille.
* Retourne toujours un résultat (fallback sur rules en cas d'erreur).
*/
export async function classifyArticle(
titre: string,
resume: string,
fallbackFn: () => { typeVeille: AiTypeVeille },
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 },
{ role: "user", content: userContent },
],
response_format: {
type: "json_schema",
json_schema: {
name: "classification_result",
strict: true,
schema: {
type: "object",
properties: {
pertinent: { type: "boolean" },
typeVeille: {
anyOf: [
{ type: "string", enum: ["reglementaire", "concurrentielle", "technologique", "informationnelle"] },
{ type: "null" },
],
},
raison: { type: "string" },
},
required: ["pertinent", "typeVeille", "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; 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",
technicalError: null,
};
} catch (e) {
const technicalError = getTechnicalErrorMessage(e);
console.error("[AI Classifier] Erreur classification veille:", technicalError);
const fb = fallbackFn();
return {
relevant: true,
typeVeille: fb.typeVeille,
categorieAap: null,
reason: "Classification par règles (erreur LLM)",
classifiedBy: "rules",
technicalError,
};
}
}
/**
* 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",
technicalError: null,
};
} catch (e) {
const technicalError = getTechnicalErrorMessage(e);
console.error("[AI Classifier] Erreur classification AAP:", technicalError);
return {
relevant: true,
typeVeille: null,
categorieAap: fallbackCategorie,
reason: "Classification par règles (erreur LLM)",
classifiedBy: "rules",
technicalError,
};
}
}
// ─── Génération de résumé IA ──────────────────────────────────────────────────
/**
* 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 > 10 ? text : null;
} catch (e) {
console.error("[AI Classifier] Erreur génération résumé:", (e as Error).message);
return null;
}
}