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:
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"timestamp": 1786998105509,
|
||||
"version": "de5930f0"
|
||||
"timestamp": 1787039351851,
|
||||
"version": "85944a51"
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 },
|
||||
|
||||
145
client/src/pages/ClassificationErrors.tsx
Normal file
145
client/src/pages/ClassificationErrors.tsx
Normal 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 été 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>
|
||||
);
|
||||
}
|
||||
11
drizzle/0013_odd_grim_reaper.sql
Normal file
11
drizzle/0013_odd_grim_reaper.sql
Normal 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`)
|
||||
);
|
||||
1135
drizzle/meta/0013_snapshot.json
Normal file
1135
drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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", {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
45
server/db.ts
45
server/db.ts
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -108,6 +108,12 @@ describe("protection admin", () => {
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.users.list()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("refuse le rapport d’erreurs 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 ───────────────────────────────────────────────────────
|
||||
|
||||
5
todo.md
5
todo.md
@@ -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 d’unicité 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.
|
||||
|
||||
Reference in New Issue
Block a user