Compare commits
2 Commits
dcb726832c
...
dde3d8b6b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dde3d8b6b8 | ||
|
|
165599bc83 |
162
.manus/db/db-query-1781872697281.json
Normal file
162
.manus/db/db-query-1781872697281.json
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
{
|
||||||
|
"query": "SHOW COLUMNS FROM veille_items;",
|
||||||
|
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.63b125a8f9ca --database VepzDyqR8YkJNcqpZ729Bw --execute SHOW COLUMNS FROM veille_items;",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"Field": "id",
|
||||||
|
"Type": "int",
|
||||||
|
"Null": "NO",
|
||||||
|
"Key": "PRI",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": "auto_increment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "dedupKey",
|
||||||
|
"Type": "varchar(64)",
|
||||||
|
"Null": "NO",
|
||||||
|
"Key": "UNI",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "titre",
|
||||||
|
"Type": "text",
|
||||||
|
"Null": "NO",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "categorie",
|
||||||
|
"Type": "varchar(128)",
|
||||||
|
"Null": "YES",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "niveau",
|
||||||
|
"Type": "varchar(128)",
|
||||||
|
"Null": "YES",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "territoire",
|
||||||
|
"Type": "varchar(255)",
|
||||||
|
"Null": "YES",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "resume",
|
||||||
|
"Type": "text",
|
||||||
|
"Null": "YES",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "source",
|
||||||
|
"Type": "varchar(512)",
|
||||||
|
"Null": "YES",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "passage",
|
||||||
|
"Type": "text",
|
||||||
|
"Null": "YES",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "lien",
|
||||||
|
"Type": "text",
|
||||||
|
"Null": "YES",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "typeVeille",
|
||||||
|
"Type": "enum('reglementaire','concurrentielle','technologique','informationnelle')",
|
||||||
|
"Null": "NO",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "datePublication",
|
||||||
|
"Type": "timestamp",
|
||||||
|
"Null": "YES",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "importedAt",
|
||||||
|
"Type": "timestamp",
|
||||||
|
"Null": "NO",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "CURRENT_TIMESTAMP",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "territoires",
|
||||||
|
"Type": "text",
|
||||||
|
"Null": "YES",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "iaRelevant",
|
||||||
|
"Type": "tinyint(1)",
|
||||||
|
"Null": "YES",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "iaCategorie",
|
||||||
|
"Type": "varchar(128)",
|
||||||
|
"Null": "YES",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "iaClassifiedBy",
|
||||||
|
"Type": "enum('ia','rules')",
|
||||||
|
"Null": "YES",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "iaReason",
|
||||||
|
"Type": "text",
|
||||||
|
"Null": "YES",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Field": "iaResume",
|
||||||
|
"Type": "text",
|
||||||
|
"Null": "YES",
|
||||||
|
"Key": "",
|
||||||
|
"Default": "NULL",
|
||||||
|
"Extra": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"messages": [],
|
||||||
|
"stdout": "Field\tType\tNull\tKey\tDefault\tExtra\nid\tint\tNO\tPRI\tNULL\tauto_increment\ndedupKey\tvarchar(64)\tNO\tUNI\tNULL\t\ntitre\ttext\tNO\t\tNULL\t\ncategorie\tvarchar(128)\tYES\t\tNULL\t\nniveau\tvarchar(128)\tYES\t\tNULL\t\nterritoire\tvarchar(255)\tYES\t\tNULL\t\nresume\ttext\tYES\t\tNULL\t\nsource\tvarchar(512)\tYES\t\tNULL\t\npassage\ttext\tYES\t\tNULL\t\nlien\ttext\tYES\t\tNULL\t\ntypeVeille\tenum('reglementaire','concurrentielle','technologique','informationnelle')\tNO\t\tNULL\t\ndatePublication\ttimestamp\tYES\t\tNULL\t\nimportedAt\ttimestamp\tNO\t\tCURRENT_TIMESTAMP\t\nterritoires\ttext\tYES\t\tNULL\t\niaRelevant\ttinyint(1)\tYES\t\tNULL\t\niaCategorie\tvarchar(128)\tYES\t\tNULL\t\niaClassifiedBy\tenum('ia','rules')\tYES\t\tNULL\t\niaReason\ttext\tYES\t\tNULL\t\niaResume\ttext\tYES\t\tNULL\t\n",
|
||||||
|
"stderr": "",
|
||||||
|
"execution_time_ms": 713
|
||||||
|
}
|
||||||
40
.manus/db/db-query-1781872703449.json
Normal file
40
.manus/db/db-query-1781872703449.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"query": "\nSELECT \n titre,\n LEFT(resume, 200) AS resume_rss,\n CHAR_LENGTH(resume) AS longueur,\n typeVeille,\n iaRelevant,\n iaClassifiedBy,\n LEFT(iaReason, 200) AS iaReason,\n lien\nFROM veille_items\nWHERE CHAR_LENGTH(resume) < 150 OR resume IS NULL\nORDER BY CHAR_LENGTH(resume) ASC\nLIMIT 25;\n",
|
||||||
|
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.63b125a8f9ca --database VepzDyqR8YkJNcqpZ729Bw --execute \nSELECT \n titre,\n LEFT(resume, 200) AS resume_rss,\n CHAR_LENGTH(resume) AS longueur,\n typeVeille,\n iaRelevant,\n iaClassifiedBy,\n LEFT(iaReason, 200) AS iaReason,\n lien\nFROM veille_items\nWHERE CHAR_LENGTH(resume) < 150 OR resume IS NULL\nORDER BY CHAR_LENGTH(resume) ASC\nLIMIT 25;\n",
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"titre": "Décision n°2025.0056/DC/SCES du 1er avril 2025 du Président de la Haute Autorité de santé portant nomination d’experts-visiteurs pour la certification des établissements de santé",
|
||||||
|
"resume_rss": "NULL",
|
||||||
|
"longueur": "NULL",
|
||||||
|
"typeVeille": "reglementaire",
|
||||||
|
"iaRelevant": "1",
|
||||||
|
"iaClassifiedBy": "ia",
|
||||||
|
"iaReason": "Le texte concerne la certification des établissements de santé par la Haute Autorité de santé, relevant du système de soin et de l'organisation des établissements hospitaliers.",
|
||||||
|
"lien": "https://www.has-sante.fr/jcms/p_3861382/fr/decision-n2025-0056/dc/sces-du-1er-avril-2025-du-president-de-la-haute-autorite-de-sante-portant-nomination-d-experts-visiteurs-pour-la-certification-des-etablissements-de-sante"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"titre": "Avenant n°2 au PRS 2023–2028 - Publication de l’arrêté portant révision du PRS le 25 février 2026",
|
||||||
|
"resume_rss": "Le Projet Régional de Santé (PRS) 2023-2028 de l’Occitanie actuellement en vigueur, vient pour la seconde fois d’être révisé partiellement.",
|
||||||
|
"longueur": "139",
|
||||||
|
"typeVeille": "reglementaire",
|
||||||
|
"iaRelevant": "1",
|
||||||
|
"iaClassifiedBy": "ia",
|
||||||
|
"iaReason": "Le texte concerne la révision d'un Projet Régional de Santé (PRS) par arrêté, relevant de l'organisation du système de santé et de la réglementation.",
|
||||||
|
"lien": "https://www.occitanie.ars.sante.fr/avenant-ndeg2-au-prs-2023-2028-publication-de-larrete-portant-revision-du-prs-le-25-fevrier-2026-0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"titre": "Que pensent les personnes handicapées de leur MDPH en 2025 ?",
|
||||||
|
"resume_rss": "Parmi les répondants, 7 personnes sur 10 sont très satisfaites ou satisfaites de leur maison départementale des personnes handicapées (MDPH).",
|
||||||
|
"longueur": "141",
|
||||||
|
"typeVeille": "informationnelle",
|
||||||
|
"iaRelevant": "1",
|
||||||
|
"iaClassifiedBy": "ia",
|
||||||
|
"iaReason": "Le texte aborde la satisfaction des personnes handicapées vis-à-vis de la MDPH, une structure dédiée à ce public.",
|
||||||
|
"lien": "https://www.cnsa.fr/actualites/que-pensent-les-personnes-handicapees-de-leur-mdph-en-2025"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"messages": [],
|
||||||
|
"stdout": "titre\tresume_rss\tlongueur\ttypeVeille\tiaRelevant\tiaClassifiedBy\tiaReason\tlien\nDécision n°2025.0056/DC/SCES du 1er avril 2025 du Président de la Haute Autorité de santé portant nomination d’experts-visiteurs pour la certification des établissements de santé\tNULL\tNULL\treglementaire\t1\tia\tLe texte concerne la certification des établissements de santé par la Haute Autorité de santé, relevant du système de soin et de l'organisation des établissements hospitaliers.\thttps://www.has-sante.fr/jcms/p_3861382/fr/decision-n2025-0056/dc/sces-du-1er-avril-2025-du-president-de-la-haute-autorite-de-sante-portant-nomination-d-experts-visiteurs-pour-la-certification-des-etablissements-de-sante\nAvenant n°2 au PRS 2023–2028 - Publication de l’arrêté portant révision du PRS le 25 février 2026\tLe Projet Régional de Santé (PRS) 2023-2028 de l’Occitanie actuellement en vigueur, vient pour la seconde fois d’être révisé partiellement.\t139\treglementaire\t1\tia\tLe texte concerne la révision d'un Projet Régional de Santé (PRS) par arrêté, relevant de l'organisation du système de santé et de la réglementation.\thttps://www.occitanie.ars.sante.fr/avenant-ndeg2-au-prs-2023-2028-publication-de-larrete-portant-revision-du-prs-le-25-fevrier-2026-0\nQue pensent les personnes handicapées de leur MDPH en 2025 ?\tParmi les répondants, 7 personnes sur 10 sont très satisfaites ou satisfaites de leur maison départementale des personnes handicapées (MDPH).\t141\tinformationnelle\t1\tia\tLe texte aborde la satisfaction des personnes handicapées vis-à-vis de la MDPH, une structure dédiée à ce public.\thttps://www.cnsa.fr/actualites/que-pensent-les-personnes-handicapees-de-leur-mdph-en-2025\n",
|
||||||
|
"stderr": "",
|
||||||
|
"execution_time_ms": 682
|
||||||
|
}
|
||||||
9
.manus/db/db-query-1781872709420.json
Normal file
9
.manus/db/db-query-1781872709420.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"query": "\nSELECT \n titre,\n LEFT(resume, 250) AS resume_rss,\n CHAR_LENGTH(resume) AS longueur,\n typeVeille,\n iaRelevant,\n iaClassifiedBy,\n LEFT(iaReason, 250) AS iaReason\nFROM veille_items\nWHERE iaClassifiedBy = 'rules' OR iaRelevant = 0\nORDER BY importedAt DESC\nLIMIT 20;\n",
|
||||||
|
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.63b125a8f9ca --database VepzDyqR8YkJNcqpZ729Bw --execute \nSELECT \n titre,\n LEFT(resume, 250) AS resume_rss,\n CHAR_LENGTH(resume) AS longueur,\n typeVeille,\n iaRelevant,\n iaClassifiedBy,\n LEFT(iaReason, 250) AS iaReason\nFROM veille_items\nWHERE iaClassifiedBy = 'rules' OR iaRelevant = 0\nORDER BY importedAt DESC\nLIMIT 20;\n",
|
||||||
|
"rows": [],
|
||||||
|
"messages": [],
|
||||||
|
"stdout": "",
|
||||||
|
"stderr": "",
|
||||||
|
"execution_time_ms": 590
|
||||||
|
}
|
||||||
282
.manus/db/db-query-1781872715777.json
Normal file
282
.manus/db/db-query-1781872715777.json
Normal file
File diff suppressed because one or more lines are too long
8
.manus/db/db-query-error-1781872691396.json
Normal file
8
.manus/db/db-query-error-1781872691396.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"query": "\nSELECT \n titre,\n LEFT(resume, 200) AS resume_court,\n CHAR_LENGTH(resume) AS longueur_resume,\n typeVeille,\n iaType,\n iaResume,\n lien\nFROM veille_items\nWHERE CHAR_LENGTH(resume) < 150\nORDER BY CHAR_LENGTH(resume) ASC\nLIMIT 20;\n",
|
||||||
|
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.63b125a8f9ca --database VepzDyqR8YkJNcqpZ729Bw --execute \nSELECT \n titre,\n LEFT(resume, 200) AS resume_court,\n CHAR_LENGTH(resume) AS longueur_resume,\n typeVeille,\n iaType,\n iaResume,\n lien\nFROM veille_items\nWHERE CHAR_LENGTH(resume) < 150\nORDER BY CHAR_LENGTH(resume) ASC\nLIMIT 20;\n",
|
||||||
|
"returncode": 1,
|
||||||
|
"logs": [
|
||||||
|
"ERROR 1054 (42S22) at line 2: Unknown column 'iatype' in 'field list'"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": "7708bf54",
|
"version": "6a49fe67",
|
||||||
"timestamp": 1781683337686
|
"timestamp": 1781875679562
|
||||||
}
|
}
|
||||||
@@ -32,26 +32,22 @@ export interface AiClassificationResult {
|
|||||||
|
|
||||||
// ─── Prompt unique : pertinence + type de veille ─────────────────────────────
|
// ─── Prompt unique : pertinence + type de veille ─────────────────────────────
|
||||||
|
|
||||||
const PROMPT_CLASSIFICATION = `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.
|
const PROMPT_CLASSIFICATION = `Tu es un expert du secteur social, médico-social et sanitaire en France, spécialisé dans les associations gestionnaires d'établissements et services.
|
||||||
|
|
||||||
Ta mission est d'analyser un article et de déterminer :
|
Analyse attentivement le texte fourni.
|
||||||
1. S'il est DIRECTEMENT pertinent pour une association gestionnaire d'ESMS
|
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.
|
||||||
2. Si oui, à quel type de veille il appartient
|
|
||||||
|
|
||||||
Un article est pertinent UNIQUEMENT s'il traite explicitement de l'un des sujets suivants :
|
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 :
|
||||||
- Réglementation, lois, décrets, circulaires concernant les ESMS, le secteur social ou médico-social
|
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
|
||||||
- Financement, tarification, dotations des structures sociales et médico-sociales (CPOM, SERAFIN-PH, etc.)
|
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
|
||||||
- Politiques publiques ciblant les personnes handicapées, âgées, enfants en danger, personnes précaires
|
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
|
||||||
- Fonctionnement, organisation, ressources humaines des ESMS
|
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
|
||||||
- Droits et accompagnement des usagers des ESMS
|
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
|
||||||
- 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 :
|
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, rénovation énergétique, etc.
|
- Il traite de politique générale, économie, international, environnement, sport, culture, agriculture, pêche, tourisme, immobilier, technologie grand public, rénovation énergétique, etc.
|
||||||
- Il mentionne des personnes vulnérables de façon anecdotique sans lien avec les ESMS
|
- Il mentionne des personnes vulnérables de façon anecdotique sans lien direct avec les 5 secteurs ci-dessus
|
||||||
- Il concerne la santé uniquement sous l'angle hospitalier ou médical sans lien avec le médico-social
|
- Le lien avec les secteurs d'activité de l'association est vague, indirect ou nécessite plusieurs degrés de déduction
|
||||||
- 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.
|
Sois STRICT : en cas de doute, réponds pertinent: false.
|
||||||
|
|
||||||
@@ -103,16 +99,22 @@ function parseJsonSafe<T>(text: string): T | null {
|
|||||||
* Détermine simultanément la pertinence et le 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 titre Titre de l'article
|
||||||
* @param resume Résumé / description de l'article
|
* @param resume Résumé / description de l'article
|
||||||
* @param fallbackFn Fonction de fallback appelée si l'IA échoue
|
* @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,
|
||||||
resume: string,
|
resume: string,
|
||||||
fallbackFn: () => { typeVeille: AiTypeVeille }
|
fallbackFn: () => { typeVeille: AiTypeVeille },
|
||||||
|
contenuPage?: string | null
|
||||||
): Promise<AiClassificationResult> {
|
): Promise<AiClassificationResult> {
|
||||||
const userContent = `Titre : ${titre}\n\nRésumé : ${resume}`;
|
// Construire le contenu utilisateur en enrichissant avec le contenu de la page si disponible
|
||||||
|
const contenuSection = contenuPage
|
||||||
|
? `\n\nExtrait de l'article (premiers paragraphes) : ${contenuPage}`
|
||||||
|
: "";
|
||||||
|
const userContent = `Titre : ${titre}\n\nRésumé RSS : ${resume}${contenuSection}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await invokeLLM({
|
const response = await invokeLLM({
|
||||||
|
|||||||
94
server/articleFetcher.ts
Normal file
94
server/articleFetcher.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* articleFetcher.ts
|
||||||
|
*
|
||||||
|
* Récupère le contenu textuel des 500 premiers mots d'un article web
|
||||||
|
* pour enrichir la classification IA quand le résumé RSS est trop court.
|
||||||
|
*
|
||||||
|
* Stratégie :
|
||||||
|
* - Fetch HTTP avec timeout 5 secondes
|
||||||
|
* - Extraction des balises <p>, <h1>–<h3> du HTML brut (sans dépendance lourde)
|
||||||
|
* - Limite à 500 mots pour rester dans le contexte LLM
|
||||||
|
* - Retourne null en cas d'erreur (timeout, 403, paywall, JS-only…) — silencieux
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FETCH_TIMEOUT_MS = 5_000;
|
||||||
|
const MAX_WORDS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait le texte brut des balises de contenu d'un HTML.
|
||||||
|
* Approche légère sans parser DOM complet.
|
||||||
|
*/
|
||||||
|
function extractTextFromHtml(html: string): string {
|
||||||
|
// Supprimer les balises script, style, nav, header, footer, aside
|
||||||
|
let cleaned = html
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||||
|
.replace(/<nav[\s\S]*?<\/nav>/gi, " ")
|
||||||
|
.replace(/<header[\s\S]*?<\/header>/gi, " ")
|
||||||
|
.replace(/<footer[\s\S]*?<\/footer>/gi, " ")
|
||||||
|
.replace(/<aside[\s\S]*?<\/aside>/gi, " ")
|
||||||
|
.replace(/<noscript[\s\S]*?<\/noscript>/gi, " ");
|
||||||
|
|
||||||
|
// Extraire le contenu des balises de texte pertinentes
|
||||||
|
const contentTags = cleaned.match(/<(p|h[1-3]|li|blockquote)[^>]*>([\s\S]*?)<\/\1>/gi) ?? [];
|
||||||
|
|
||||||
|
const texts = contentTags.map((tag) =>
|
||||||
|
tag
|
||||||
|
.replace(/<[^>]+>/g, " ") // supprimer les balises HTML restantes
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/&#\d+;/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
return texts.filter((t) => t.length > 20).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère les premiers ~500 mots du contenu d'un article web.
|
||||||
|
* Retourne null si l'article est inaccessible ou si le contenu est trop pauvre.
|
||||||
|
*
|
||||||
|
* @param url URL de l'article
|
||||||
|
* @returns Texte extrait (500 mots max) ou null
|
||||||
|
*/
|
||||||
|
export async function fetchArticleFirstParagraph(url: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
"User-Agent":
|
||||||
|
"Mozilla/5.0 (compatible; VeilleBot/1.0; +https://itinova.fr)",
|
||||||
|
Accept: "text/html,application/xhtml+xml",
|
||||||
|
"Accept-Language": "fr-FR,fr;q=0.9",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
if (!response.ok) return null;
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") ?? "";
|
||||||
|
if (!contentType.includes("text/html")) return null;
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
const text = extractTextFromHtml(html);
|
||||||
|
|
||||||
|
if (!text || text.length < 50) return null;
|
||||||
|
|
||||||
|
// Limiter à MAX_WORDS mots
|
||||||
|
const words = text.split(/\s+/);
|
||||||
|
const truncated = words.slice(0, MAX_WORDS).join(" ");
|
||||||
|
|
||||||
|
return truncated.length > 50 ? truncated : null;
|
||||||
|
} catch {
|
||||||
|
// Timeout, réseau, CORS, paywall, etc. — silencieux
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} 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, generateSummary } from "./aiClassifier";
|
||||||
|
import { fetchArticleFirstParagraph } from "./articleFetcher";
|
||||||
|
|
||||||
// ─── Types internes ───────────────────────────────────────────────────────────
|
// ─── Types internes ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -351,9 +352,14 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
|||||||
const dedupKey = dedupHash(normalizedTitle + "|" + (feed.feedType ?? ""));
|
const dedupKey = dedupHash(normalizedTitle + "|" + (feed.feedType ?? ""));
|
||||||
|
|
||||||
if (feed.feedType === "veille") {
|
if (feed.feedType === "veille") {
|
||||||
// ── Classification IA (avec fallback sur mots-clés) ──────────────────
|
// ── Classification IA (avec fallback sur mots-clés) ──────────────────────────────────────
|
||||||
const { niveau, territoire } = detectVeilleNiveauTerritoire(fullText);
|
const { niveau, territoire } = detectVeilleNiveauTerritoire(fullText);
|
||||||
|
|
||||||
|
// Enrichissement : récupérer le 1er paragraphe si le résumé RSS est court (<150 car.)
|
||||||
|
const contenuPage = description.length < 150 && link
|
||||||
|
? await fetchArticleFirstParagraph(link)
|
||||||
|
: null;
|
||||||
|
|
||||||
const aiResult = await classifyArticle(
|
const aiResult = await classifyArticle(
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@@ -363,7 +369,8 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
|||||||
typeVeille: (matchedRule?.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
|
typeVeille: (matchedRule?.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
|
||||||
"reglementaire" | "concurrentielle" | "technologique" | "informationnelle",
|
"reglementaire" | "concurrentielle" | "technologique" | "informationnelle",
|
||||||
};
|
};
|
||||||
}
|
},
|
||||||
|
contenuPage
|
||||||
);
|
);
|
||||||
|
|
||||||
const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
|
const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
|
||||||
@@ -420,12 +427,18 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
|||||||
// ── Classification IA pour les AAP ───────────────────────────────────
|
// ── Classification IA pour les AAP ───────────────────────────────────
|
||||||
const { region, departement } = detectAapGeo(fullText);
|
const { region, departement } = detectAapGeo(fullText);
|
||||||
|
|
||||||
|
// Enrichissement : récupérer le 1er paragraphe si le résumé RSS est court (<150 car.)
|
||||||
|
const contenuPageAap = description.length < 150 && link
|
||||||
|
? await fetchArticleFirstParagraph(link)
|
||||||
|
: null;
|
||||||
|
|
||||||
const aiResult = await classifyArticle(
|
const aiResult = await classifyArticle(
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
() => ({
|
() => ({
|
||||||
typeVeille: "informationnelle" as const,
|
typeVeille: "informationnelle" as const,
|
||||||
})
|
}),
|
||||||
|
contenuPageAap
|
||||||
);
|
);
|
||||||
|
|
||||||
const categorie = (feed.defaultCategorieAap ?? "Autre") as
|
const categorie = (feed.defaultCategorieAap ?? "Autre") as
|
||||||
|
|||||||
12
todo.md
12
todo.md
@@ -150,3 +150,15 @@
|
|||||||
- [x] VeilleDashboard.tsx : supprimer tout filtre ou affichage lié à iaCategorie
|
- [x] VeilleDashboard.tsx : supprimer tout filtre ou affichage lié à iaCategorie
|
||||||
- [x] Tester visuellement en sandbox
|
- [x] Tester visuellement en sandbox
|
||||||
- [x] Checkpoint
|
- [x] Checkpoint
|
||||||
|
|
||||||
|
## Amélioration prompt IA — 5 secteurs d'activité
|
||||||
|
- [x] aiClassifier.ts : remplacer le prompt de pertinence générique ESMS par une définition explicite des 5 secteurs (handicap, précarité/logement, protection de l'enfance, personnes âgées, sanitaire SMR)
|
||||||
|
- [x] 0 erreur TypeScript, 21 tests passés
|
||||||
|
- [x] Checkpoint
|
||||||
|
|
||||||
|
## Récupération du 1er paragraphe pour la classification IA
|
||||||
|
- [x] Créer server/articleFetcher.ts : fetch HTTP avec timeout 5s, extraction des 500 premiers mots du contenu textuel (balises p, h1-h3), fallback silencieux si erreur
|
||||||
|
- [x] Intégrer articleFetcher dans rssEngine.ts : enrichir le contenu envoyé à classifyArticle avec le 1er paragraphe récupéré
|
||||||
|
- [x] Mettre à jour la signature de classifyArticle pour accepter un paramètre optionnel contenuPage
|
||||||
|
- [x] 0 erreur TypeScript, tests passés
|
||||||
|
- [x] Checkpoint
|
||||||
|
|||||||
Reference in New Issue
Block a user