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:
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user