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,
|
"timestamp": 1787039351851,
|
||||||
"version": "de5930f0"
|
"version": "85944a51"
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ const AAPDashboard = lazy(() => import("./pages/AAPDashboard"));
|
|||||||
const Settings = lazy(() => import("./pages/Settings"));
|
const Settings = lazy(() => import("./pages/Settings"));
|
||||||
const UsersAdmin = lazy(() => import("./pages/UsersAdmin"));
|
const UsersAdmin = lazy(() => import("./pages/UsersAdmin"));
|
||||||
const ImportLogs = lazy(() => import("./pages/ImportLogs"));
|
const ImportLogs = lazy(() => import("./pages/ImportLogs"));
|
||||||
|
const ClassificationErrors = lazy(() => import("./pages/ClassificationErrors"));
|
||||||
const BoiteAIdees = lazy(() => import("@/pages/BoiteAIdees"));
|
const BoiteAIdees = lazy(() => import("@/pages/BoiteAIdees"));
|
||||||
const RssFeeds = lazy(() => import("@/pages/RssFeeds"));
|
const RssFeeds = lazy(() => import("@/pages/RssFeeds"));
|
||||||
const AzureCallback = lazy(() => import("@/pages/AzureCallback"));
|
const AzureCallback = lazy(() => import("@/pages/AzureCallback"));
|
||||||
@@ -104,6 +105,16 @@ function LogsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ClassificationErrorsPage() {
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<DashboardWrapper>
|
||||||
|
<ClassificationErrors />
|
||||||
|
</DashboardWrapper>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BoiteAIdeesPage() {
|
function BoiteAIdeesPage() {
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
@@ -139,6 +150,7 @@ function Router() {
|
|||||||
<Route path="/admin/settings" component={SettingsPage} />
|
<Route path="/admin/settings" component={SettingsPage} />
|
||||||
<Route path="/admin/users" component={UsersPage} />
|
<Route path="/admin/users" component={UsersPage} />
|
||||||
<Route path="/admin/logs" component={LogsPage} />
|
<Route path="/admin/logs" component={LogsPage} />
|
||||||
|
<Route path="/admin/classification-errors" component={ClassificationErrorsPage} />
|
||||||
<Route path="/boite-a-idees" component={BoiteAIdeesPage} />
|
<Route path="/boite-a-idees" component={BoiteAIdeesPage} />
|
||||||
<Route path="/admin/rss" component={RssFeedsPage} />
|
<Route path="/admin/rss" component={RssFeedsPage} />
|
||||||
<Route path="/404" component={NotFound} />
|
<Route path="/404" component={NotFound} />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
Rss,
|
Rss,
|
||||||
|
AlertTriangle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -64,6 +65,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
defaultOpen: false,
|
defaultOpen: false,
|
||||||
items: [
|
items: [
|
||||||
{ label: "Logs d'import", href: "/admin/logs", icon: <Activity size={16} />, adminOnly: true },
|
{ 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: "Utilisateurs", href: "/admin/users", icon: <Users size={16} />, adminOnly: true },
|
||||||
{ label: "Flux RSS", href: "/admin/rss", icon: <Rss 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 },
|
{ 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,
|
"when": 1786997741455,
|
||||||
"tag": "0012_fluffy_boomer",
|
"tag": "0012_fluffy_boomer",
|
||||||
"breakpoints": true
|
"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 ImportLog = typeof importLogs.$inferSelect;
|
||||||
export type InsertImportLog = typeof importLogs.$inferInsert;
|
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 ───────────────────────────────────────────────────────────
|
// ─── Boîte à idées ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const ideas = mysqlTable("ideas", {
|
export const ideas = mysqlTable("ideas", {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ describe("classifyArticle", () => {
|
|||||||
expect(result.typeVeille).toBe("informationnelle");
|
expect(result.typeVeille).toBe("informationnelle");
|
||||||
expect(result.relevant).toBe(true); // on suppose pertinent par défaut
|
expect(result.relevant).toBe(true); // on suppose pertinent par défaut
|
||||||
expect(result.reason).toContain("règles");
|
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 () => {
|
it("retourne le fallback (rules) si le LLM retourne un JSON malformé", async () => {
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ export interface AiClassificationResult {
|
|||||||
reason: string;
|
reason: string;
|
||||||
/** Indique si la classification a été faite par l'IA ou par les règles (fallback) */
|
/** Indique si la classification a été faite par l'IA ou par les règles (fallback) */
|
||||||
classifiedBy: "ia" | "rules";
|
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 ─────────────────
|
// ─── Prompt veille stratégique : pertinence + type de veille ─────────────────
|
||||||
@@ -194,9 +201,11 @@ export async function classifyArticle(
|
|||||||
categorieAap: null,
|
categorieAap: null,
|
||||||
reason: parsed.raison,
|
reason: parsed.raison,
|
||||||
classifiedBy: "ia",
|
classifiedBy: "ia",
|
||||||
|
technicalError: null,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} 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();
|
const fb = fallbackFn();
|
||||||
return {
|
return {
|
||||||
relevant: true,
|
relevant: true,
|
||||||
@@ -204,6 +213,7 @@ export async function classifyArticle(
|
|||||||
categorieAap: null,
|
categorieAap: null,
|
||||||
reason: "Classification par règles (erreur LLM)",
|
reason: "Classification par règles (erreur LLM)",
|
||||||
classifiedBy: "rules",
|
classifiedBy: "rules",
|
||||||
|
technicalError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,15 +275,18 @@ export async function classifyAap(
|
|||||||
categorieAap: parsed.pertinent ? (parsed.categorie ?? "Autre") : null,
|
categorieAap: parsed.pertinent ? (parsed.categorie ?? "Autre") : null,
|
||||||
reason: parsed.raison,
|
reason: parsed.raison,
|
||||||
classifiedBy: "ia",
|
classifiedBy: "ia",
|
||||||
|
technicalError: null,
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} 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 {
|
return {
|
||||||
relevant: true,
|
relevant: true,
|
||||||
typeVeille: null,
|
typeVeille: null,
|
||||||
categorieAap: fallbackCategorie,
|
categorieAap: fallbackCategorie,
|
||||||
reason: "Classification par règles (erreur LLM)",
|
reason: "Classification par règles (erreur LLM)",
|
||||||
classifiedBy: "rules",
|
classifiedBy: "rules",
|
||||||
|
technicalError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
server/db.ts
45
server/db.ts
@@ -16,9 +16,11 @@ import {
|
|||||||
rssSettings,
|
rssSettings,
|
||||||
processedDedupKeys,
|
processedDedupKeys,
|
||||||
articleReads,
|
articleReads,
|
||||||
|
classificationErrors,
|
||||||
type InsertRssFeed,
|
type InsertRssFeed,
|
||||||
type InsertRssSettings,
|
type InsertRssSettings,
|
||||||
type ImportLog,
|
type ImportLog,
|
||||||
|
type ClassificationError,
|
||||||
type RssFeed,
|
type RssFeed,
|
||||||
type RssSettings,
|
type RssSettings,
|
||||||
} from "../drizzle/schema";
|
} 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 ────────────────────────────────────────────────────────────
|
// ─── Boîte à idées ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function createIdea(data: InsertIdea) {
|
export async function createIdea(data: InsertIdea) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
setSettings,
|
setSettings,
|
||||||
getImportLogs,
|
getImportLogs,
|
||||||
getImportStats,
|
getImportStats,
|
||||||
|
getClassificationErrors,
|
||||||
getLocalUsers,
|
getLocalUsers,
|
||||||
createLocalUser,
|
createLocalUser,
|
||||||
updateLocalUser,
|
updateLocalUser,
|
||||||
@@ -520,6 +521,11 @@ export const appRouter = router({
|
|||||||
return getRssFeeds();
|
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
|
// Créer un flux
|
||||||
create: adminProcedure
|
create: adminProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
import { XMLParser } from "fast-xml-parser";
|
import { XMLParser } from "fast-xml-parser";
|
||||||
import * as crypto from "crypto";
|
import * as crypto from "crypto";
|
||||||
import { getDb, removeArticleReadRecords } from "./db";
|
import { getDb, recordClassificationError, removeArticleReadRecords } from "./db";
|
||||||
import {
|
import {
|
||||||
rssFeeds,
|
rssFeeds,
|
||||||
veilleItems,
|
veilleItems,
|
||||||
@@ -563,6 +563,16 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
|||||||
},
|
},
|
||||||
contenuPage
|
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
|
const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
|
||||||
"reglementaire" | "concurrentielle" | "technologique" | "informationnelle";
|
"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",
|
(feed.defaultCategorieAap ?? "Autre") as "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre",
|
||||||
contenuPageAap
|
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
|
const categorie = (aiResult.categorieAap ?? feed.defaultCategorieAap ?? "Autre") as
|
||||||
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
|
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,12 @@ describe("protection admin", () => {
|
|||||||
const caller = appRouter.createCaller(ctx);
|
const caller = appRouter.createCaller(ctx);
|
||||||
await expect(caller.users.list()).rejects.toThrow();
|
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 ───────────────────────────────────────────────────────
|
// ─── 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] 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] 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.
|
- [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