Checkpoint: Préparation recette : ajout du journal classification_errors, exposition de la cause technique des fallbacks IA, enregistrement non bloquant depuis le moteur RSS, écran d’administration Erreurs IA, route protégée et migration 0013. Validation locale : 28 tests, TypeScript et build de production réussis.

This commit is contained in:
Manus
2026-08-18 07:49:11 +00:00
parent e4a389cc67
commit 9acc52da7d
15 changed files with 1432 additions and 5 deletions

View File

@@ -1,4 +1,4 @@
{
"timestamp": 1786998105509,
"version": "de5930f0"
"timestamp": 1787039351851,
"version": "85944a51"
}

View File

@@ -17,6 +17,7 @@ const AAPDashboard = lazy(() => import("./pages/AAPDashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const UsersAdmin = lazy(() => import("./pages/UsersAdmin"));
const ImportLogs = lazy(() => import("./pages/ImportLogs"));
const ClassificationErrors = lazy(() => import("./pages/ClassificationErrors"));
const BoiteAIdees = lazy(() => import("@/pages/BoiteAIdees"));
const RssFeeds = lazy(() => import("@/pages/RssFeeds"));
const AzureCallback = lazy(() => import("@/pages/AzureCallback"));
@@ -104,6 +105,16 @@ function LogsPage() {
);
}
function ClassificationErrorsPage() {
return (
<AuthGuard>
<DashboardWrapper>
<ClassificationErrors />
</DashboardWrapper>
</AuthGuard>
);
}
function BoiteAIdeesPage() {
return (
<AuthGuard>
@@ -139,6 +150,7 @@ function Router() {
<Route path="/admin/settings" component={SettingsPage} />
<Route path="/admin/users" component={UsersPage} />
<Route path="/admin/logs" component={LogsPage} />
<Route path="/admin/classification-errors" component={ClassificationErrorsPage} />
<Route path="/boite-a-idees" component={BoiteAIdeesPage} />
<Route path="/admin/rss" component={RssFeedsPage} />
<Route path="/404" component={NotFound} />

View File

@@ -18,6 +18,7 @@ import {
X,
Lightbulb,
Rss,
AlertTriangle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -64,6 +65,7 @@ const NAV_GROUPS: NavGroup[] = [
defaultOpen: false,
items: [
{ label: "Logs d'import", href: "/admin/logs", icon: <Activity size={16} />, adminOnly: true },
{ label: "Erreurs IA", href: "/admin/classification-errors", icon: <AlertTriangle size={16} />, adminOnly: true },
{ label: "Utilisateurs", href: "/admin/users", icon: <Users size={16} />, adminOnly: true },
{ label: "Flux RSS", href: "/admin/rss", icon: <Rss size={16} />, adminOnly: true },
{ label: "Paramètres", href: "/admin/settings", icon: <Settings size={16} />, adminOnly: true },

View File

@@ -0,0 +1,145 @@
import { useState } from "react";
import { AlertTriangle, ChevronLeft, ChevronRight, ExternalLink, Loader2, RefreshCw, Rss } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface ClassificationError {
id: number;
feedName: string;
feedType: "veille" | "aap";
articleTitle: string;
articleUrl: string | null;
errorMessage: string;
occurredAt: Date;
}
const PAGE_SIZE = 25;
const typeConfig = {
veille: { label: "Veille", className: "bg-blue-100 text-blue-800 border-blue-200" },
aap: { label: "AAP", className: "bg-violet-100 text-violet-800 border-violet-200" },
};
function formatDate(value: Date) {
return new Intl.DateTimeFormat("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(value));
}
/** Rapport des erreurs LLM ayant déclenché le fallback de classification RSS. */
export default function ClassificationErrors() {
const [page, setPage] = useState(1);
const errorsQuery = trpc.rss.classificationErrors.useQuery({ page, pageSize: PAGE_SIZE });
const errors = (errorsQuery.data?.errors ?? []) as ClassificationError[];
const total = errorsQuery.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return (
<div className="p-6 space-y-6 animate-fade-up">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-1">
<AlertTriangle size={22} className="text-amber-600" />
<h1 className="text-2xl font-bold text-foreground">Erreurs de classification</h1>
</div>
<p className="text-sm text-muted-foreground">
Fallbacks IA détectés pendant la lecture des flux RSS. Les articles concernés ont é importés avec les règles de repli.
</p>
</div>
<Button variant="outline" size="sm" className="gap-2" onClick={() => errorsQuery.refetch()} disabled={errorsQuery.isFetching}>
<RefreshCw size={15} className={cn(errorsQuery.isFetching && "animate-spin")} />
Actualiser
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Card className="border-amber-200 bg-amber-50/50">
<CardContent className="p-4">
<div className="flex items-center gap-2 text-amber-700 mb-1">
<AlertTriangle size={16} />
<span className="text-xs font-medium">Fallbacks enregistrés</span>
</div>
<p className="text-2xl font-bold text-amber-800">{total}</p>
</CardContent>
</Card>
<Card className="border-border/50">
<CardContent className="p-4">
<div className="flex items-center gap-2 text-primary mb-1">
<Rss size={16} />
<span className="text-xs font-medium text-muted-foreground">Comportement de sécurité</span>
</div>
<p className="text-sm font-medium text-foreground">Import maintenu via règles de repli</p>
</CardContent>
</Card>
</div>
<Card>
<CardContent className="p-0">
{errorsQuery.isLoading ? (
<div className="flex items-center justify-center py-16"><Loader2 size={28} className="animate-spin text-primary" /></div>
) : errorsQuery.isError ? (
<div className="flex flex-col items-center justify-center py-16 text-center gap-2">
<AlertTriangle size={40} className="text-destructive/60" />
<p className="font-medium text-foreground">Le rapport ne peut pas être chargé</p>
<p className="text-sm text-muted-foreground">{errorsQuery.error.message}</p>
</div>
) : errors.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<AlertTriangle size={40} className="text-emerald-500/60 mb-3" />
<p className="font-medium text-foreground">Aucune erreur de classification</p>
<p className="text-sm text-muted-foreground mt-1">Les prochains fallbacks IA apparaîtront ici.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-3 font-semibold text-muted-foreground whitespace-nowrap">Date</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-24">Flux</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Article</th>
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Cause technique</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{errors.map((error) => {
const type = typeConfig[error.feedType];
return (
<tr key={error.id} className="hover:bg-muted/20 transition-colors align-top">
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">{formatDate(error.occurredAt)}</td>
<td className="px-4 py-3">
<div className="space-y-1">
<Badge variant="outline" className={cn("text-xs", type.className)}>{type.label}</Badge>
<p className="text-xs text-muted-foreground max-w-40 truncate" title={error.feedName}>{error.feedName}</p>
</div>
</td>
<td className="px-4 py-3 max-w-sm">
{error.articleUrl ? (
<a href={error.articleUrl} target="_blank" rel="noreferrer" className="inline-flex items-start gap-1 font-medium text-primary hover:underline">
<span>{error.articleTitle}</span><ExternalLink size={13} className="mt-0.5 shrink-0" />
</a>
) : <span className="font-medium text-foreground">{error.articleTitle}</span>}
</td>
<td className="px-4 py-3 text-xs text-destructive max-w-md break-words">{error.errorMessage}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button variant="outline" size="sm" onClick={() => setPage((value) => Math.max(1, value - 1))} disabled={page === 1}><ChevronLeft size={14} /></Button>
<span className="text-sm text-muted-foreground px-2">Page {page} / {totalPages}</span>
<Button variant="outline" size="sm" onClick={() => setPage((value) => Math.min(totalPages, value + 1))} disabled={page === totalPages}><ChevronRight size={14} /></Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,11 @@
CREATE TABLE `classification_errors` (
`id` int AUTO_INCREMENT NOT NULL,
`feedId` int,
`feedName` varchar(255) NOT NULL,
`feedType` enum('veille','aap') NOT NULL,
`articleTitle` text NOT NULL,
`articleUrl` text,
`errorMessage` text NOT NULL,
`occurredAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `classification_errors_id` PRIMARY KEY(`id`)
);

File diff suppressed because it is too large Load Diff

View File

@@ -92,6 +92,13 @@
"when": 1786997741455,
"tag": "0012_fluffy_boomer",
"breakpoints": true
},
{
"idx": 13,
"version": "5",
"when": 1787039231200,
"tag": "0013_odd_grim_reaper",
"breakpoints": true
}
]
}

View File

@@ -137,6 +137,25 @@ export const importLogs = mysqlTable("import_logs", {
export type ImportLog = typeof importLogs.$inferSelect;
export type InsertImportLog = typeof importLogs.$inferInsert;
// ─── Erreurs de classification RSS ───────────────────────────────────────────
/**
* Journal technique destiné à l'administration. Il conserve les fallbacks IA
* article par article, sans bloquer l'import ni exposer l'erreur aux utilisateurs.
*/
export const classificationErrors = mysqlTable("classification_errors", {
id: int("id").autoincrement().primaryKey(),
feedId: int("feedId"),
feedName: varchar("feedName", { length: 255 }).notNull(),
feedType: mysqlEnum("feedType", ["veille", "aap"]).notNull(),
articleTitle: text("articleTitle").notNull(),
articleUrl: text("articleUrl"),
errorMessage: text("errorMessage").notNull(),
occurredAt: timestamp("occurredAt").defaultNow().notNull(),
});
export type ClassificationError = typeof classificationErrors.$inferSelect;
// ─── Boîte à idées ───────────────────────────────────────────────────────────
export const ideas = mysqlTable("ideas", {

View File

@@ -56,6 +56,7 @@ describe("classifyArticle", () => {
expect(result.typeVeille).toBe("informationnelle");
expect(result.relevant).toBe(true); // on suppose pertinent par défaut
expect(result.reason).toContain("règles");
expect(result.technicalError).toBe("LLM timeout");
});
it("retourne le fallback (rules) si le LLM retourne un JSON malformé", async () => {

View File

@@ -38,6 +38,13 @@ export interface AiClassificationResult {
reason: string;
/** Indique si la classification a été faite par l'IA ou par les règles (fallback) */
classifiedBy: "ia" | "rules";
/** Cause technique du fallback, réservée au journal d'administration. */
technicalError: string | null;
}
function getTechnicalErrorMessage(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message.slice(0, 1000);
}
// ─── Prompt veille stratégique : pertinence + type de veille ─────────────────
@@ -194,9 +201,11 @@ export async function classifyArticle(
categorieAap: null,
reason: parsed.raison,
classifiedBy: "ia",
technicalError: null,
};
} catch (e) {
console.error("[AI Classifier] Erreur classification veille:", (e as Error).message);
const technicalError = getTechnicalErrorMessage(e);
console.error("[AI Classifier] Erreur classification veille:", technicalError);
const fb = fallbackFn();
return {
relevant: true,
@@ -204,6 +213,7 @@ export async function classifyArticle(
categorieAap: null,
reason: "Classification par règles (erreur LLM)",
classifiedBy: "rules",
technicalError,
};
}
}
@@ -265,15 +275,18 @@ export async function classifyAap(
categorieAap: parsed.pertinent ? (parsed.categorie ?? "Autre") : null,
reason: parsed.raison,
classifiedBy: "ia",
technicalError: null,
};
} catch (e) {
console.error("[AI Classifier] Erreur classification AAP:", (e as Error).message);
const technicalError = getTechnicalErrorMessage(e);
console.error("[AI Classifier] Erreur classification AAP:", technicalError);
return {
relevant: true,
typeVeille: null,
categorieAap: fallbackCategorie,
reason: "Classification par règles (erreur LLM)",
classifiedBy: "rules",
technicalError,
};
}
}

View File

@@ -16,9 +16,11 @@ import {
rssSettings,
processedDedupKeys,
articleReads,
classificationErrors,
type InsertRssFeed,
type InsertRssSettings,
type ImportLog,
type ClassificationError,
type RssFeed,
type RssSettings,
} from "../drizzle/schema";
@@ -489,6 +491,49 @@ export async function getImportStats() {
};
}
// ─── Rapport d'erreurs de classification RSS ──────────────────────────────────
export interface ClassificationErrorInput {
feedId: number | null;
feedName: string;
feedType: "veille" | "aap";
articleTitle: string;
articleUrl: string | null;
errorMessage: string;
}
/**
* Enregistre un fallback IA sans interrompre le traitement du flux RSS.
* La collecte reste opérationnelle : l'erreur est visible dans l'administration
* tandis que l'article est classé par la règle de repli prévue.
*/
export async function recordClassificationError(input: ClassificationErrorInput): Promise<void> {
const db = await getDb();
if (!db) {
console.error("[Classification errors] Base indisponible : erreur non journalisée");
return;
}
try {
await db.insert(classificationErrors).values(input);
} catch (error) {
console.error("[Classification errors] Échec de journalisation :", error);
}
}
/** Liste paginée des fallbacks IA, réservée aux administrateurs via le routeur. */
export async function getClassificationErrors(page: number, pageSize: number): Promise<{ errors: ClassificationError[]; total: number }> {
const db = await getDb();
if (!db) return { errors: [], total: 0 };
const offset = (page - 1) * pageSize;
const [errors, totals] = await Promise.all([
db.select().from(classificationErrors).orderBy(desc(classificationErrors.occurredAt)).limit(pageSize).offset(offset),
db.select({ total: count() }).from(classificationErrors),
]);
return { errors, total: Number(totals[0]?.total ?? 0) };
}
// ─── Boîte à idées ────────────────────────────────────────────────────────────
export async function createIdea(data: InsertIdea) {

View File

@@ -15,6 +15,7 @@ import {
setSettings,
getImportLogs,
getImportStats,
getClassificationErrors,
getLocalUsers,
createLocalUser,
updateLocalUser,
@@ -520,6 +521,11 @@ export const appRouter = router({
return getRssFeeds();
}),
// Consulter les fallbacks IA par article, sans exposer les erreurs aux utilisateurs standards.
classificationErrors: adminProcedure
.input(z.object({ page: z.number().int().min(1).default(1), pageSize: z.number().int().min(1).max(100).default(25) }))
.query(async ({ input }) => getClassificationErrors(input.page, input.pageSize)),
// Créer un flux
create: adminProcedure
.input(z.object({

View File

@@ -14,7 +14,7 @@
*/
import { XMLParser } from "fast-xml-parser";
import * as crypto from "crypto";
import { getDb, removeArticleReadRecords } from "./db";
import { getDb, recordClassificationError, removeArticleReadRecords } from "./db";
import {
rssFeeds,
veilleItems,
@@ -563,6 +563,16 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
},
contenuPage
);
if (aiResult.technicalError) {
await recordClassificationError({
feedId: feed.id,
feedName: feed.name,
feedType: "veille",
articleTitle: title,
articleUrl: link || null,
errorMessage: aiResult.technicalError,
});
}
const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
"reglementaire" | "concurrentielle" | "technologique" | "informationnelle";
@@ -636,6 +646,16 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
(feed.defaultCategorieAap ?? "Autre") as "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre",
contenuPageAap
);
if (aiResult.technicalError) {
await recordClassificationError({
feedId: feed.id,
feedName: feed.name,
feedType: "aap",
articleTitle: title,
articleUrl: link || null,
errorMessage: aiResult.technicalError,
});
}
const categorie = (aiResult.categorieAap ?? feed.defaultCategorieAap ?? "Autre") as
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";

View File

@@ -108,6 +108,12 @@ describe("protection admin", () => {
const caller = appRouter.createCaller(ctx);
await expect(caller.users.list()).rejects.toThrow();
});
it("refuse le rapport derreurs de classification pour un non admin", async () => {
const ctx = makeUserCtx();
const caller = appRouter.createCaller(ctx);
await expect(caller.rss.classificationErrors({ page: 1, pageSize: 25 })).rejects.toThrow();
});
});
// ─── Tests accès public ───────────────────────────────────────────────────────

View File

@@ -197,3 +197,8 @@
- [x] Ajouter des tests unitaires couvrant la persistance et la restitution des articles lus.
- [x] Supprimer les composants non référencés et les artefacts de développement du dépôt applicatif.
- [x] Vérifier TypeScript, Vitest et le build de production après refactoring.
## Déploiement recette et supervision de classification
- [ ] Déployer le refactoring et appliquer la migration dunicité des lectures en recette.
- [ ] Tester en recette la persistance des articles lus après déconnexion et reconnexion.
- [x] Ajouter un rapport administrateur des erreurs de classification RSS avec date, flux, article et cause.