Checkpoint: Classification IA en deux étapes (filtre pertinence + classification catégorie) intégrée dans le moteur RSS. Inclut : module aiClassifier.ts avec les deux prompts, intégration dans rssEngine.ts avec fallback sur règles, procédures tRPC reclassifyWithAI pour veille et AAP, badge IA violet dans VeilleDashboard, bouton "Classer avec l'IA" (admin), section "Analyse par l'IA" dans la boîte de dialogue détail, 7 nouveaux tests Vitest (20 tests au total). Migration BDD appliquée (4 nouveaux champs IA dans veille_items et aap_items).
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "cf512535",
|
||||
"timestamp": 1780256687344
|
||||
"version": "dab72037",
|
||||
"timestamp": 1780479118639
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
BookOpen,
|
||||
Trash2,
|
||||
AlertTriangle,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -61,6 +62,10 @@ interface VeilleItem {
|
||||
lien: string | null;
|
||||
datePublication: Date | null;
|
||||
importedAt: Date;
|
||||
iaRelevant: boolean | null;
|
||||
iaCategorie: string | null;
|
||||
iaClassifiedBy: "ia" | "rules" | null;
|
||||
iaReason: string | null;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<TypeVeille, string> = {
|
||||
@@ -206,6 +211,47 @@ function VeilleDetailDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Analyse IA */}
|
||||
{item.iaClassifiedBy && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={14} className="text-violet-500" />
|
||||
<h3 className="text-sm font-semibold text-foreground">Analyse par l'IA</h3>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"p-4 rounded-lg border space-y-2",
|
||||
item.iaRelevant === false
|
||||
? "bg-orange-50 border-orange-200"
|
||||
: item.iaClassifiedBy === "ia"
|
||||
? "bg-violet-50 border-violet-200"
|
||||
: "bg-muted/20 border-border/50"
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full",
|
||||
item.iaRelevant === false
|
||||
? "bg-orange-100 text-orange-700 border border-orange-300"
|
||||
: item.iaClassifiedBy === "ia"
|
||||
? "bg-violet-100 text-violet-700 border border-violet-300"
|
||||
: "bg-muted text-muted-foreground border border-border"
|
||||
)}>
|
||||
<Sparkles size={10} />
|
||||
{item.iaClassifiedBy === "ia" ? "Classé par l'IA" : "Classé par règles"}
|
||||
</span>
|
||||
{item.iaRelevant === false && (
|
||||
<span className="text-xs text-orange-700 font-medium">Hors périmètre médico-social</span>
|
||||
)}
|
||||
{item.iaRelevant === true && item.iaCategorie && (
|
||||
<span className="text-xs text-violet-700 font-medium">{item.iaCategorie}</span>
|
||||
)}
|
||||
</div>
|
||||
{item.iaReason && (
|
||||
<p className="text-xs text-foreground/70 leading-relaxed italic">"{item.iaReason}"</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lien externe */}
|
||||
{item.lien && (
|
||||
<div className="pt-1 border-t border-border/50">
|
||||
@@ -232,6 +278,15 @@ export default function VeilleDashboard() {
|
||||
const { user } = useLocalAuth();
|
||||
const isAdmin = user?.role === "admin";
|
||||
const utils = trpc.useUtils();
|
||||
const reclassifyMutation = trpc.veille.reclassifyWithAI.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Classification IA terminée — ${data.processed} article(s) traité(s)${data.errors > 0 ? `, ${data.errors} erreur(s)` : ""}`);
|
||||
utils.veille.list.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(`Erreur lors de la classification IA : ${err.message}`);
|
||||
},
|
||||
});
|
||||
const purgeMutation = trpc.veille.purge.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(`Purge effectuée — ${data.deleted} entrée(s) supprimée(s)`);
|
||||
@@ -313,6 +368,18 @@ export default function VeilleDashboard() {
|
||||
<LayoutGrid size={15} />Vignettes
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 border-primary/50 text-primary hover:bg-primary hover:text-primary-foreground"
|
||||
onClick={() => reclassifyMutation.mutate({ limit: 50 })}
|
||||
disabled={reclassifyMutation.isPending}
|
||||
title="Reclassifier les 50 prochains articles non encore traités par l'IA"
|
||||
>
|
||||
{reclassifyMutation.isPending ? <Loader2 size={15} className="animate-spin" /> : <Sparkles size={15} />}
|
||||
{reclassifyMutation.isPending ? "Classification en cours..." : "Classer avec l'IA"}
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2 border-destructive/50 text-destructive hover:bg-destructive hover:text-destructive-foreground ml-2">
|
||||
@@ -345,6 +412,7 @@ export default function VeilleDashboard() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -442,7 +510,25 @@ function VeilleListView({ items, onDetail }: { items: VeilleItem[]; onDetail: (i
|
||||
{TYPE_LABELS[item.typeVeille as TypeVeille] || item.typeVeille}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs">{item.categorie || "—"}</td>
|
||||
<td className="px-4 py-3 text-xs">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground">{item.categorie || "—"}</span>
|
||||
{item.iaClassifiedBy === "ia" && (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded-full w-fit",
|
||||
item.iaRelevant === false
|
||||
? "bg-orange-100 text-orange-700 border border-orange-200"
|
||||
: "bg-violet-100 text-violet-700 border border-violet-200"
|
||||
)}
|
||||
title={item.iaReason || "Classé par l'IA"}
|
||||
>
|
||||
<Sparkles size={9} />
|
||||
{item.iaRelevant === false ? "Hors périmètre" : "IA"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs">{item.niveau || "—"}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs">{item.territoire || "—"}</td>
|
||||
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">{formatDate(item.datePublication) || "—"}</td>
|
||||
|
||||
8
drizzle/0007_clammy_blue_marvel.sql
Normal file
8
drizzle/0007_clammy_blue_marvel.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE `aap_items` ADD `iaRelevant` boolean;--> statement-breakpoint
|
||||
ALTER TABLE `aap_items` ADD `iaCategorie` varchar(128);--> statement-breakpoint
|
||||
ALTER TABLE `aap_items` ADD `iaClassifiedBy` enum('ia','rules');--> statement-breakpoint
|
||||
ALTER TABLE `aap_items` ADD `iaReason` text;--> statement-breakpoint
|
||||
ALTER TABLE `veille_items` ADD `iaRelevant` boolean;--> statement-breakpoint
|
||||
ALTER TABLE `veille_items` ADD `iaCategorie` varchar(128);--> statement-breakpoint
|
||||
ALTER TABLE `veille_items` ADD `iaClassifiedBy` enum('ia','rules');--> statement-breakpoint
|
||||
ALTER TABLE `veille_items` ADD `iaReason` text;
|
||||
918
drizzle/meta/0007_snapshot.json
Normal file
918
drizzle/meta/0007_snapshot.json
Normal file
@@ -0,0 +1,918 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "7dc085c2-224c-40d4-817b-3fa3190acef0",
|
||||
"prevId": "a1057ebe-2a3e-40d5-b599-6e9abe8f38d7",
|
||||
"tables": {
|
||||
"aap_items": {
|
||||
"name": "aap_items",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"dedupKey": {
|
||||
"name": "dedupKey",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"titre": {
|
||||
"name": "titre",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"categorie": {
|
||||
"name": "categorie",
|
||||
"type": "enum('Handicap','PA','Enfance','Précarité','Sanitaire','Autre')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"region": {
|
||||
"name": "region",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"departement": {
|
||||
"name": "departement",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"departements": {
|
||||
"name": "departements",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"dateCloture": {
|
||||
"name": "dateCloture",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"datePublication": {
|
||||
"name": "datePublication",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"lien": {
|
||||
"name": "lien",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"importedAt": {
|
||||
"name": "importedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"iaRelevant": {
|
||||
"name": "iaRelevant",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"iaCategorie": {
|
||||
"name": "iaCategorie",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"iaClassifiedBy": {
|
||||
"name": "iaClassifiedBy",
|
||||
"type": "enum('ia','rules')",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"iaReason": {
|
||||
"name": "iaReason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"aap_items_id": {
|
||||
"name": "aap_items_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"aap_items_dedupKey_unique": {
|
||||
"name": "aap_items_dedupKey_unique",
|
||||
"columns": [
|
||||
"dedupKey"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"app_settings": {
|
||||
"name": "app_settings",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"app_settings_id": {
|
||||
"name": "app_settings_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"app_settings_key_unique": {
|
||||
"name": "app_settings_key_unique",
|
||||
"columns": [
|
||||
"key"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"ideas": {
|
||||
"name": "ideas",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userName": {
|
||||
"name": "userName",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"titre": {
|
||||
"name": "titre",
|
||||
"type": "varchar(512)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"statut": {
|
||||
"name": "statut",
|
||||
"type": "enum('ouvert','en_cours','resolu','ferme')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'ouvert'"
|
||||
},
|
||||
"reponseAdmin": {
|
||||
"name": "reponseAdmin",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reponduPar": {
|
||||
"name": "reponduPar",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reponduAt": {
|
||||
"name": "reponduAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"ideas_id": {
|
||||
"name": "ideas_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"import_logs": {
|
||||
"name": "import_logs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"fileType": {
|
||||
"name": "fileType",
|
||||
"type": "enum('veille','aap')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "varchar(512)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "enum('success','partial','error')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"totalRows": {
|
||||
"name": "totalRows",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"newRows": {
|
||||
"name": "newRows",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"skippedRows": {
|
||||
"name": "skippedRows",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"errorMessage": {
|
||||
"name": "errorMessage",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"details": {
|
||||
"name": "details",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"startedAt": {
|
||||
"name": "startedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"completedAt": {
|
||||
"name": "completedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"import_logs_id": {
|
||||
"name": "import_logs_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"local_users": {
|
||||
"name": "local_users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"username": {
|
||||
"name": "username",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(320)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"passwordHash": {
|
||||
"name": "passwordHash",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "enum('admin','user','readonly')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"isActive": {
|
||||
"name": "isActive",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
},
|
||||
"lastSignedIn": {
|
||||
"name": "lastSignedIn",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"local_users_id": {
|
||||
"name": "local_users_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"local_users_username_unique": {
|
||||
"name": "local_users_username_unique",
|
||||
"columns": [
|
||||
"username"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"rss_feeds": {
|
||||
"name": "rss_feeds",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"feedType": {
|
||||
"name": "feedType",
|
||||
"type": "enum('veille','aap')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"defaultTypeVeille": {
|
||||
"name": "defaultTypeVeille",
|
||||
"type": "enum('reglementaire','concurrentielle','technologique','generale')",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"defaultCategorieAap": {
|
||||
"name": "defaultCategorieAap",
|
||||
"type": "enum('Handicap','PA','Enfance','Précarité','Sanitaire','Autre')",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"autoRules": {
|
||||
"name": "autoRules",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"isActive": {
|
||||
"name": "isActive",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"lastFetchedAt": {
|
||||
"name": "lastFetchedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"lastFetchStatus": {
|
||||
"name": "lastFetchStatus",
|
||||
"type": "enum('ok','error','pending')",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"lastFetchError": {
|
||||
"name": "lastFetchError",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"rss_feeds_id": {
|
||||
"name": "rss_feeds_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"rss_settings": {
|
||||
"name": "rss_settings",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"fetchIntervalMinutes": {
|
||||
"name": "fetchIntervalMinutes",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 360
|
||||
},
|
||||
"scheduledTime": {
|
||||
"name": "scheduledTime",
|
||||
"type": "varchar(5)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'06:00'"
|
||||
},
|
||||
"fetchMode": {
|
||||
"name": "fetchMode",
|
||||
"type": "enum('interval','scheduled')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'scheduled'"
|
||||
},
|
||||
"autoFetchEnabled": {
|
||||
"name": "autoFetchEnabled",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"rss_settings_id": {
|
||||
"name": "rss_settings_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"openId": {
|
||||
"name": "openId",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(320)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"loginMethod": {
|
||||
"name": "loginMethod",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "enum('user','admin')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
},
|
||||
"lastSignedIn": {
|
||||
"name": "lastSignedIn",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"users_id": {
|
||||
"name": "users_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"users_openId_unique": {
|
||||
"name": "users_openId_unique",
|
||||
"columns": [
|
||||
"openId"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"veille_items": {
|
||||
"name": "veille_items",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"dedupKey": {
|
||||
"name": "dedupKey",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"titre": {
|
||||
"name": "titre",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"categorie": {
|
||||
"name": "categorie",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"niveau": {
|
||||
"name": "niveau",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"territoire": {
|
||||
"name": "territoire",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"territoires": {
|
||||
"name": "territoires",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resume": {
|
||||
"name": "resume",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "varchar(512)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"passage": {
|
||||
"name": "passage",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"lien": {
|
||||
"name": "lien",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"typeVeille": {
|
||||
"name": "typeVeille",
|
||||
"type": "enum('reglementaire','concurrentielle','technologique','generale')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"datePublication": {
|
||||
"name": "datePublication",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"importedAt": {
|
||||
"name": "importedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"iaRelevant": {
|
||||
"name": "iaRelevant",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"iaCategorie": {
|
||||
"name": "iaCategorie",
|
||||
"type": "varchar(128)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"iaClassifiedBy": {
|
||||
"name": "iaClassifiedBy",
|
||||
"type": "enum('ia','rules')",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"iaReason": {
|
||||
"name": "iaReason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"veille_items_id": {
|
||||
"name": "veille_items_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"veille_items_dedupKey_unique": {
|
||||
"name": "veille_items_dedupKey_unique",
|
||||
"columns": [
|
||||
"dedupKey"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"tables": {},
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,13 @@
|
||||
"when": 1777805205749,
|
||||
"tag": "0006_fixed_lady_deathstrike",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "5",
|
||||
"when": 1780478622510,
|
||||
"tag": "0007_clammy_blue_marvel",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -77,6 +77,11 @@ export const veilleItems = mysqlTable("veille_items", {
|
||||
// Date extraite de la colonne Source (qui contient parfois une date ISO)
|
||||
datePublication: timestamp("datePublication"),
|
||||
importedAt: timestamp("importedAt").defaultNow().notNull(),
|
||||
// Classification IA
|
||||
iaRelevant: boolean("iaRelevant"),
|
||||
iaCategorie: varchar("iaCategorie", { length: 128 }),
|
||||
iaClassifiedBy: mysqlEnum("iaClassifiedBy", ["ia", "rules"]),
|
||||
iaReason: text("iaReason"),
|
||||
});
|
||||
|
||||
export type VeilleItem = typeof veilleItems.$inferSelect;
|
||||
@@ -97,6 +102,11 @@ export const aapItems = mysqlTable("aap_items", {
|
||||
datePublication: timestamp("datePublication"),
|
||||
lien: text("lien"),
|
||||
importedAt: timestamp("importedAt").defaultNow().notNull(),
|
||||
// Classification IA
|
||||
iaRelevant: boolean("iaRelevant"),
|
||||
iaCategorie: varchar("iaCategorie", { length: 128 }),
|
||||
iaClassifiedBy: mysqlEnum("iaClassifiedBy", ["ia", "rules"]),
|
||||
iaReason: text("iaReason"),
|
||||
});
|
||||
|
||||
export type AapItem = typeof aapItems.$inferSelect;
|
||||
|
||||
166
server/aiClassifier.test.ts
Normal file
166
server/aiClassifier.test.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Tests Vitest pour server/aiClassifier.ts
|
||||
*
|
||||
* Ces tests vérifient le comportement du module de classification IA :
|
||||
* - Fallback sur les règles en cas d'erreur LLM
|
||||
* - Résultat "hors périmètre" quand pertinent = false
|
||||
* - Classification correcte quand les deux étapes réussissent
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// ─── Mock du module LLM ───────────────────────────────────────────────────────
|
||||
vi.mock("./_core/llm", () => ({
|
||||
invokeLLM: vi.fn(),
|
||||
}));
|
||||
|
||||
import { invokeLLM } from "./_core/llm";
|
||||
import { classifyArticle } from "./aiClassifier";
|
||||
|
||||
const mockInvokeLLM = vi.mocked(invokeLLM);
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeLLMResponse(content: string) {
|
||||
return {
|
||||
id: "test",
|
||||
created: Date.now(),
|
||||
model: "test",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant" as const, content },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackFn = () => ({
|
||||
categorie: "Autre" as const,
|
||||
typeVeille: "generale" as const,
|
||||
});
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("classifyArticle", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("retourne le fallback (rules) si le LLM échoue à l'étape 1 (pertinence)", async () => {
|
||||
mockInvokeLLM.mockRejectedValueOnce(new Error("LLM timeout"));
|
||||
|
||||
const result = await classifyArticle("Titre test", "Résumé test", fallbackFn);
|
||||
|
||||
expect(result.classifiedBy).toBe("rules");
|
||||
expect(result.categorie).toBe("Autre");
|
||||
expect(result.relevant).toBe(true); // on suppose pertinent par défaut
|
||||
});
|
||||
|
||||
it("retourne le fallback (rules) si le LLM retourne un JSON malformé à l'étape 1", async () => {
|
||||
mockInvokeLLM.mockResolvedValueOnce(makeLLMResponse("pas du JSON valide ici"));
|
||||
|
||||
const result = await classifyArticle("Titre test", "Résumé test", fallbackFn);
|
||||
|
||||
expect(result.classifiedBy).toBe("rules");
|
||||
expect(result.categorie).toBe("Autre");
|
||||
});
|
||||
|
||||
it("retourne relevant=false et categorie=Autre si l'article est hors périmètre", async () => {
|
||||
mockInvokeLLM.mockResolvedValueOnce(
|
||||
makeLLMResponse(JSON.stringify({ pertinent: false, raison: "Actualité politique générale sans lien médico-social" }))
|
||||
);
|
||||
|
||||
const result = await classifyArticle(
|
||||
"Résultats des élections législatives",
|
||||
"Les partis politiques ont obtenu les résultats suivants...",
|
||||
fallbackFn
|
||||
);
|
||||
|
||||
expect(result.relevant).toBe(false);
|
||||
expect(result.categorie).toBe("Autre");
|
||||
expect(result.classifiedBy).toBe("ia");
|
||||
expect(result.reason).toContain("politique");
|
||||
});
|
||||
|
||||
it("classifie correctement un article pertinent en deux étapes", async () => {
|
||||
// Étape 1 : pertinent = true
|
||||
mockInvokeLLM.mockResolvedValueOnce(
|
||||
makeLLMResponse(JSON.stringify({ pertinent: true, raison: "Concerne les EHPAD et la perte d'autonomie" }))
|
||||
);
|
||||
// Étape 2 : classification
|
||||
mockInvokeLLM.mockResolvedValueOnce(
|
||||
makeLLMResponse(
|
||||
JSON.stringify({
|
||||
categorie: "PA",
|
||||
typeVeille: "reglementaire",
|
||||
raison: "Décret relatif au financement des EHPAD",
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const result = await classifyArticle(
|
||||
"Nouveau décret sur le financement des EHPAD",
|
||||
"Le gouvernement publie un décret encadrant les tarifs des établissements pour personnes âgées dépendantes.",
|
||||
fallbackFn
|
||||
);
|
||||
|
||||
expect(result.relevant).toBe(true);
|
||||
expect(result.categorie).toBe("PA");
|
||||
expect(result.typeVeille).toBe("reglementaire");
|
||||
expect(result.classifiedBy).toBe("ia");
|
||||
});
|
||||
|
||||
it("retourne le fallback (rules) si l'étape 2 (classification) échoue", async () => {
|
||||
// Étape 1 : pertinent = true
|
||||
mockInvokeLLM.mockResolvedValueOnce(
|
||||
makeLLMResponse(JSON.stringify({ pertinent: true, raison: "Article pertinent" }))
|
||||
);
|
||||
// Étape 2 : erreur LLM
|
||||
mockInvokeLLM.mockRejectedValueOnce(new Error("LLM quota exceeded"));
|
||||
|
||||
const result = await classifyArticle("Titre pertinent", "Résumé pertinent", fallbackFn);
|
||||
|
||||
expect(result.classifiedBy).toBe("rules");
|
||||
expect(result.relevant).toBe(true);
|
||||
expect(result.reason).toContain("Pertinent");
|
||||
});
|
||||
|
||||
it("accepte les réponses LLM avec balises markdown ```json```", async () => {
|
||||
// Étape 1 avec balises markdown
|
||||
mockInvokeLLM.mockResolvedValueOnce(
|
||||
makeLLMResponse("```json\n" + JSON.stringify({ pertinent: false, raison: "Hors périmètre" }) + "\n```")
|
||||
);
|
||||
|
||||
const result = await classifyArticle("Titre sport", "Résultats du championnat de football", fallbackFn);
|
||||
|
||||
expect(result.relevant).toBe(false);
|
||||
expect(result.classifiedBy).toBe("ia");
|
||||
});
|
||||
|
||||
it("classe un article handicap correctement", async () => {
|
||||
mockInvokeLLM.mockResolvedValueOnce(
|
||||
makeLLMResponse(JSON.stringify({ pertinent: true, raison: "Concerne les IME et l'inclusion scolaire" }))
|
||||
);
|
||||
mockInvokeLLM.mockResolvedValueOnce(
|
||||
makeLLMResponse(
|
||||
JSON.stringify({
|
||||
categorie: "Handicap",
|
||||
typeVeille: "reglementaire",
|
||||
raison: "Circulaire sur l'inclusion scolaire des enfants en situation de handicap",
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const result = await classifyArticle(
|
||||
"Circulaire sur l'inclusion scolaire des enfants handicapés",
|
||||
"Le ministère publie une circulaire relative à l'accompagnement des élèves en situation de handicap dans les IME.",
|
||||
fallbackFn
|
||||
);
|
||||
|
||||
expect(result.categorie).toBe("Handicap");
|
||||
expect(result.typeVeille).toBe("reglementaire");
|
||||
expect(result.classifiedBy).toBe("ia");
|
||||
});
|
||||
});
|
||||
268
server/aiClassifier.ts
Normal file
268
server/aiClassifier.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* 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) évoluant dans les secteurs suivants: handicap, personnes âgées, protection de l'enfance, précarité, sanitaire.
|
||||
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, en te concentrant spécifiquement sur :
|
||||
- Les services, dispositifs politiques et financement liés au handicap
|
||||
- L'accompagnement des personnes âgées, les soins associés et la perte d'autonomie
|
||||
- La gestion sanitaire et les questions de santé
|
||||
- Les services de protection de l'enfance
|
||||
- Les problématiques et situations de précarité, d'exclusion, de vulnérabilité, d'accès aux droits ou d'insertion
|
||||
Le tout dans une perspective professionnelle.
|
||||
Prends en compte le public principal concerné, les institutions impliquées (État, ARS, conseils départementaux, justice, etc.) ainsi que les conséquences potentielles pour les structures médico-sociales.
|
||||
Si le contenu relève uniquement d'actualités politiques générales, économiques, internationales ou sociétales sans lien clair avec le secteur médico-social, réponds avec 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, répondre "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;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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",
|
||||
};
|
||||
}
|
||||
@@ -35,6 +35,10 @@ import {
|
||||
import { importVeille, importAAP, runFullImport, getImportConfig } from "./importer";
|
||||
import { scheduleRssFetch } from "./_core/index";
|
||||
import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth";
|
||||
import { classifyArticle } from "./aiClassifier";
|
||||
import { getDb } from "./db";
|
||||
import { veilleItems, aapItems } from "../drizzle/schema";
|
||||
import { isNull, or, eq as eqDrizzle } from "drizzle-orm";
|
||||
|
||||
// ─── Middleware admin ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -105,6 +109,49 @@ export const appRouter = router({
|
||||
const count = await purgeVeilleItems();
|
||||
return { success: true, deleted: count };
|
||||
}),
|
||||
|
||||
reclassifyWithAI: adminProcedure
|
||||
.input(z.object({ limit: z.number().int().positive().max(200).default(50) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const db = await getDb();
|
||||
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
||||
|
||||
// Récupérer les articles non encore classés par l'IA
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(veilleItems)
|
||||
.where(or(isNull(veilleItems.iaClassifiedBy), isNull(veilleItems.iaRelevant)))
|
||||
.limit(input.limit);
|
||||
|
||||
let processed = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const aiResult = await classifyArticle(
|
||||
row.titre || "",
|
||||
row.resume || "",
|
||||
() => ({ categorie: (row.categorie as any) || "Autre", typeVeille: row.typeVeille || "generale" })
|
||||
);
|
||||
await db
|
||||
.update(veilleItems)
|
||||
.set({
|
||||
categorie: aiResult.categorie,
|
||||
typeVeille: (aiResult.typeVeille ?? row.typeVeille ?? "generale") as any,
|
||||
iaRelevant: aiResult.relevant,
|
||||
iaCategorie: aiResult.categorie,
|
||||
iaClassifiedBy: aiResult.classifiedBy,
|
||||
iaReason: aiResult.reason,
|
||||
})
|
||||
.where(eqDrizzle(veilleItems.id, row.id));
|
||||
processed++;
|
||||
} catch {
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
return { processed, errors, total: rows.length };
|
||||
}),
|
||||
}),
|
||||
// ─── AAPP ────────────────────────────────────────────────────────────────────
|
||||
aap: router({
|
||||
@@ -134,6 +181,47 @@ export const appRouter = router({
|
||||
const count = await purgeAapItems();
|
||||
return { success: true, deleted: count };
|
||||
}),
|
||||
|
||||
reclassifyWithAI: adminProcedure
|
||||
.input(z.object({ limit: z.number().int().positive().max(200).default(50) }))
|
||||
.mutation(async ({ input }) => {
|
||||
const db = await getDb();
|
||||
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(aapItems)
|
||||
.where(or(isNull(aapItems.iaClassifiedBy), isNull(aapItems.iaRelevant)))
|
||||
.limit(input.limit);
|
||||
|
||||
let processed = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
const aiResult = await classifyArticle(
|
||||
row.titre || "",
|
||||
"",
|
||||
() => ({ categorie: (row.categorie as any) || "Autre", typeVeille: "generale" as const })
|
||||
);
|
||||
await db
|
||||
.update(aapItems)
|
||||
.set({
|
||||
categorie: aiResult.categorie as any,
|
||||
iaRelevant: aiResult.relevant,
|
||||
iaCategorie: aiResult.categorie,
|
||||
iaClassifiedBy: aiResult.classifiedBy,
|
||||
iaReason: aiResult.reason,
|
||||
})
|
||||
.where(eqDrizzle(aapItems.id, row.id));
|
||||
processed++;
|
||||
} catch {
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
return { processed, errors, total: rows.length };
|
||||
}),
|
||||
}),
|
||||
// ─── Importt ─────────────────────────────────────────────────────────────────
|
||||
import: router({
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* et insère les nouveaux articles dans veille_items ou aap_items.
|
||||
*
|
||||
* Enrichissement automatique :
|
||||
* - Classification IA en deux étapes (pertinence + catégorie) avec fallback sur mots-clés
|
||||
* - AAP : région (toujours Auvergne-Rhône-Alpes) + département extrait du titre/description
|
||||
* - Veille : territoire, catégorie (Handicap/PA/Enfance/Précarité/Sanitaire/Autre), niveau
|
||||
*
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
type RssFeed,
|
||||
} from "../drizzle/schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { classifyArticle } from "./aiClassifier";
|
||||
|
||||
// ─── Types internes ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -349,13 +351,26 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
||||
const dedupKey = dedupHash(normalizedTitle + "|" + (feed.feedType ?? ""));
|
||||
|
||||
if (feed.feedType === "veille") {
|
||||
const matchedRule = applyAutoRules(title, description, rules);
|
||||
const typeVeille = (matchedRule?.typeVeille ?? feed.defaultTypeVeille ?? "generale") as
|
||||
"reglementaire" | "concurrentielle" | "technologique" | "generale";
|
||||
|
||||
const categorie = detectVeilleCategorie(fullText);
|
||||
// ── Classification IA (avec fallback sur mots-clés) ──────────────────
|
||||
const { niveau, territoire } = detectVeilleNiveauTerritoire(fullText);
|
||||
|
||||
const aiResult = await classifyArticle(
|
||||
title,
|
||||
description,
|
||||
() => {
|
||||
const matchedRule = applyAutoRules(title, description, rules);
|
||||
return {
|
||||
categorie: detectVeilleCategorie(fullText),
|
||||
typeVeille: (matchedRule?.typeVeille ?? feed.defaultTypeVeille ?? "generale") as
|
||||
"reglementaire" | "concurrentielle" | "technologique" | "generale",
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const categorie = aiResult.categorie;
|
||||
const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "generale") as
|
||||
"reglementaire" | "concurrentielle" | "technologique" | "generale";
|
||||
|
||||
try {
|
||||
// Essayer d'insérer
|
||||
await db.insert(veilleItems).values({
|
||||
@@ -370,6 +385,10 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
||||
lien: link || null,
|
||||
typeVeille,
|
||||
datePublication: pubDate,
|
||||
iaRelevant: aiResult.relevant,
|
||||
iaCategorie: aiResult.categorie,
|
||||
iaClassifiedBy: aiResult.classifiedBy,
|
||||
iaReason: aiResult.reason,
|
||||
});
|
||||
result.newItems++;
|
||||
} catch (e: any) {
|
||||
@@ -396,12 +415,25 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
||||
}
|
||||
}
|
||||
} else if (feed.feedType === "aap") {
|
||||
const matchedRule = applyAutoRules(title, description, rules);
|
||||
const categorie = (matchedRule?.categorieAap ?? feed.defaultCategorieAap ?? "Autre") as
|
||||
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
|
||||
|
||||
// ── Classification IA pour les AAP ───────────────────────────────────
|
||||
const { region, departement } = detectAapGeo(fullText);
|
||||
|
||||
const aiResult = await classifyArticle(
|
||||
title,
|
||||
description,
|
||||
() => {
|
||||
const matchedRule = applyAutoRules(title, description, rules);
|
||||
return {
|
||||
categorie: (matchedRule?.categorieAap ?? feed.defaultCategorieAap ?? "Autre") as
|
||||
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre",
|
||||
typeVeille: "generale" as const,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const categorie = aiResult.categorie as
|
||||
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
|
||||
|
||||
try {
|
||||
await db.insert(aapItems).values({
|
||||
dedupKey,
|
||||
@@ -412,6 +444,10 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
||||
departements: JSON.stringify(departement ? [departement] : []),
|
||||
lien: link || null,
|
||||
datePublication: pubDate,
|
||||
iaRelevant: aiResult.relevant,
|
||||
iaCategorie: aiResult.categorie,
|
||||
iaClassifiedBy: aiResult.classifiedBy,
|
||||
iaReason: aiResult.reason,
|
||||
});
|
||||
result.newItems++;
|
||||
} catch (e: any) {
|
||||
|
||||
24
todo.md
24
todo.md
@@ -84,8 +84,22 @@
|
||||
- [x] Déployer sur le VPS
|
||||
|
||||
## Purge des données
|
||||
- [ ] Procédures tRPC : veille.purge et aap.purge (adminProcedure)
|
||||
- [ ] Bouton "Purger les données" en haut à droite de VeilleDashboard.tsx (admin uniquement)
|
||||
- [ ] Bouton "Purger les données" en haut à droite de AAPDashboard.tsx (admin uniquement)
|
||||
- [ ] Boîte de dialogue de confirmation avec message d'avertissement
|
||||
- [ ] Déploiement VPS
|
||||
- [x] Procédures tRPC : veille.purge et aap.purge (adminProcedure)
|
||||
- [x] Bouton "Purger les données" en haut à droite de VeilleDashboard.tsx (admin uniquement)
|
||||
- [x] Bouton "Purger les données" en haut à droite de AAPDashboard.tsx (admin uniquement)
|
||||
- [x] Boîte de dialogue de confirmation avec message d'avertissement
|
||||
- [x] Déploiement VPS
|
||||
|
||||
## Classification IA des articles RSS
|
||||
|
||||
- [x] Ajouter champs IA dans veille_items : iaRelevant (boolean nullable), iaCategorie, iaClassifiedBy, iaReason
|
||||
- [x] Ajouter champs IA dans aap_items : iaRelevant (boolean nullable), iaCategorie, iaClassifiedBy, iaReason
|
||||
- [x] Migration base de données (pnpm db:push)
|
||||
- [x] Créer server/aiClassifier.ts avec prompt 1 (filtre pertinence) et prompt 2 (classification catégorie)
|
||||
- [x] Intégrer aiClassifier dans rssEngine.ts (remplacer mots-clés par IA, fallback sur règles)
|
||||
- [x] Procédure tRPC veille.reclassifyWithAI (reclassifier les articles existants)
|
||||
- [x] Procédure tRPC aap.reclassifyWithAI
|
||||
- [x] Badge "IA" dans VeilleDashboard.tsx sur les articles classés par IA
|
||||
- [x] Bouton "Reclassifier avec l'IA" dans l'interface admin
|
||||
- [x] Tests vitest pour aiClassifier.ts
|
||||
- [x] Déploiement recette
|
||||
|
||||
Reference in New Issue
Block a user