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

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