322 lines
16 KiB
TypeScript
322 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";
|
|
}
|
|
|
|
// ─── 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.
|
|
|
|
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'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.
|
|
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).
|
|
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;
|
|
}
|
|
}
|