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:
Manus
2026-06-03 05:31:58 -04:00
parent d9cded5970
commit 9c26772617
11 changed files with 1618 additions and 17 deletions

View File

@@ -1,4 +1,4 @@
{
"version": "cf512535",
"timestamp": 1780256687344
"version": "dab72037",
"timestamp": 1780479118639
}

View File

@@ -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>