310 lines
15 KiB
TypeScript
310 lines
15 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";
|
|
}
|
|
|
|
// ─── 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",
|
|
};
|
|
} catch (e) {
|
|
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<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)",
|
|
classifiedBy: "rules",
|
|
};
|
|
}
|
|
}
|
|
|
|
// ─── 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;
|
|
}
|
|
}
|