diff --git a/client/public/__manus__/version.json b/client/public/__manus__/version.json index 5444aa9..2ee0def 100644 --- a/client/public/__manus__/version.json +++ b/client/public/__manus__/version.json @@ -1,4 +1,4 @@ { - "timestamp": 1786998105509, - "version": "de5930f0" + "timestamp": 1787039351851, + "version": "85944a51" } \ No newline at end of file diff --git a/client/src/App.tsx b/client/src/App.tsx index c1648de..4ef97f5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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 ( + + + + + + ); +} + function BoiteAIdeesPage() { return ( @@ -139,6 +150,7 @@ function Router() { + diff --git a/client/src/components/AppLayout.tsx b/client/src/components/AppLayout.tsx index 111fe07..95f687b 100644 --- a/client/src/components/AppLayout.tsx +++ b/client/src/components/AppLayout.tsx @@ -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: , adminOnly: true }, + { label: "Erreurs IA", href: "/admin/classification-errors", icon: , adminOnly: true }, { label: "Utilisateurs", href: "/admin/users", icon: , adminOnly: true }, { label: "Flux RSS", href: "/admin/rss", icon: , adminOnly: true }, { label: "Paramètres", href: "/admin/settings", icon: , adminOnly: true }, diff --git a/client/src/pages/ClassificationErrors.tsx b/client/src/pages/ClassificationErrors.tsx new file mode 100644 index 0000000..c251c7a --- /dev/null +++ b/client/src/pages/ClassificationErrors.tsx @@ -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 ( +
+
+
+
+ +

Erreurs de classification

+
+

+ 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. +

+
+ +
+ +
+ + +
+ + Fallbacks enregistrés +
+

{total}

+
+
+ + +
+ + Comportement de sécurité +
+

Import maintenu via règles de repli

+
+
+
+ + + + {errorsQuery.isLoading ? ( +
+ ) : errorsQuery.isError ? ( +
+ +

Le rapport ne peut pas être chargé

+

{errorsQuery.error.message}

+
+ ) : errors.length === 0 ? ( +
+ +

Aucune erreur de classification

+

Les prochains fallbacks IA apparaîtront ici.

+
+ ) : ( +
+ + + + + + + + + + + {errors.map((error) => { + const type = typeConfig[error.feedType]; + return ( + + + + + + + ); + })} + +
DateFluxArticleCause technique
{formatDate(error.occurredAt)} +
+ {type.label} +

{error.feedName}

+
+
+ {error.articleUrl ? ( + + {error.articleTitle} + + ) : {error.articleTitle}} + {error.errorMessage}
+
+ )} +
+
+ + {totalPages > 1 && ( +
+ + Page {page} / {totalPages} + +
+ )} +
+ ); +} diff --git a/drizzle/0013_odd_grim_reaper.sql b/drizzle/0013_odd_grim_reaper.sql new file mode 100644 index 0000000..388140f --- /dev/null +++ b/drizzle/0013_odd_grim_reaper.sql @@ -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`) +); diff --git a/drizzle/meta/0013_snapshot.json b/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000..5d25532 --- /dev/null +++ b/drizzle/meta/0013_snapshot.json @@ -0,0 +1,1135 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "16abfc91-2357-4963-a504-f4e544317178", + "prevId": "af8c04b5-9744-46e0-a2f3-12394683c8b4", + "tables": { + "aap_items": { + "name": "aap_items", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "titre": { + "name": "titre", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "categorie": { + "name": "categorie", + "type": "enum('Handicap','PA','Enfance','Précarité','Sanitaire','Autre')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "departement": { + "name": "departement", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "departements": { + "name": "departements", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dateCloture": { + "name": "dateCloture", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "datePublication": { + "name": "datePublication", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lien": { + "name": "lien", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importedAt": { + "name": "importedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "iaRelevant": { + "name": "iaRelevant", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaCategorie": { + "name": "iaCategorie", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaClassifiedBy": { + "name": "iaClassifiedBy", + "type": "enum('ia','rules')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaReason": { + "name": "iaReason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaResume": { + "name": "iaResume", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "aap_items_id": { + "name": "aap_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "aap_items_dedupKey_unique": { + "name": "aap_items_dedupKey_unique", + "columns": [ + "dedupKey" + ] + } + }, + "checkConstraint": {} + }, + "app_settings": { + "name": "app_settings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "key": { + "name": "key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "app_settings_id": { + "name": "app_settings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "app_settings_key_unique": { + "name": "app_settings_key_unique", + "columns": [ + "key" + ] + } + }, + "checkConstraint": {} + }, + "article_reads": { + "name": "article_reads", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "articleType": { + "name": "articleType", + "type": "enum('veille','aap')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "articleId": { + "name": "articleId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "readAt": { + "name": "readAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": { + "article_reads_user_type_article_unique": { + "name": "article_reads_user_type_article_unique", + "columns": [ + "userId", + "articleType", + "articleId" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "article_reads_id": { + "name": "article_reads_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "classification_errors": { + "name": "classification_errors", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "feedId": { + "name": "feedId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "feedName": { + "name": "feedName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feedType": { + "name": "feedType", + "type": "enum('veille','aap')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "articleTitle": { + "name": "articleTitle", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "articleUrl": { + "name": "articleUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occurredAt": { + "name": "occurredAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "classification_errors_id": { + "name": "classification_errors_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "ideas": { + "name": "ideas", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userName": { + "name": "userName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "titre": { + "name": "titre", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "enum('ouvert','en_cours','resolu','ferme')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ouvert'" + }, + "reponseAdmin": { + "name": "reponseAdmin", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reponduPar": { + "name": "reponduPar", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reponduAt": { + "name": "reponduAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ideas_id": { + "name": "ideas_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "import_logs": { + "name": "import_logs", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "fileType": { + "name": "fileType", + "type": "enum('veille','aap')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "enum('success','partial','error')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totalRows": { + "name": "totalRows", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "newRows": { + "name": "newRows", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "skippedRows": { + "name": "skippedRows", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "errorMessage": { + "name": "errorMessage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "details": { + "name": "details", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "startedAt": { + "name": "startedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "completedAt": { + "name": "completedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "import_logs_id": { + "name": "import_logs_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "local_users": { + "name": "local_users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('admin','user','readonly')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "azureAdId": { + "name": "azureAdId", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "local_users_id": { + "name": "local_users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "local_users_username_unique": { + "name": "local_users_username_unique", + "columns": [ + "username" + ] + }, + "local_users_azureAdId_unique": { + "name": "local_users_azureAdId_unique", + "columns": [ + "azureAdId" + ] + } + }, + "checkConstraint": {} + }, + "processed_dedup_keys": { + "name": "processed_dedup_keys", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feedType": { + "name": "feedType", + "type": "enum('veille','aap')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processedAt": { + "name": "processedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "processed_dedup_keys_id": { + "name": "processed_dedup_keys_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "processed_dedup_keys_dedupKey_unique": { + "name": "processed_dedup_keys_dedupKey_unique", + "columns": [ + "dedupKey" + ] + } + }, + "checkConstraint": {} + }, + "rss_feeds": { + "name": "rss_feeds", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "feedType": { + "name": "feedType", + "type": "enum('veille','aap')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "defaultTypeVeille": { + "name": "defaultTypeVeille", + "type": "enum('reglementaire','concurrentielle','technologique','informationnelle')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "defaultCategorieAap": { + "name": "defaultCategorieAap", + "type": "enum('Handicap','PA','Enfance','Précarité','Sanitaire','Autre')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "autoRules": { + "name": "autoRules", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "lastFetchedAt": { + "name": "lastFetchedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lastFetchStatus": { + "name": "lastFetchStatus", + "type": "enum('ok','error','pending')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "lastFetchError": { + "name": "lastFetchError", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rss_feeds_id": { + "name": "rss_feeds_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rss_settings": { + "name": "rss_settings", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "fetchIntervalMinutes": { + "name": "fetchIntervalMinutes", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 360 + }, + "scheduledTime": { + "name": "scheduledTime", + "type": "varchar(5)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'06:00'" + }, + "fetchMode": { + "name": "fetchMode", + "type": "enum('interval','scheduled')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'scheduled'" + }, + "autoFetchEnabled": { + "name": "autoFetchEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rss_settings_id": { + "name": "rss_settings_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + } + }, + "checkConstraint": {} + }, + "veille_items": { + "name": "veille_items", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "dedupKey": { + "name": "dedupKey", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "titre": { + "name": "titre", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "categorie": { + "name": "categorie", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "niveau": { + "name": "niveau", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "territoire": { + "name": "territoire", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "territoires": { + "name": "territoires", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resume": { + "name": "resume", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "passage": { + "name": "passage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lien": { + "name": "lien", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "typeVeille": { + "name": "typeVeille", + "type": "enum('reglementaire','concurrentielle','technologique','informationnelle')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "datePublication": { + "name": "datePublication", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "importedAt": { + "name": "importedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "iaRelevant": { + "name": "iaRelevant", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaCategorie": { + "name": "iaCategorie", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaClassifiedBy": { + "name": "iaClassifiedBy", + "type": "enum('ia','rules')", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaReason": { + "name": "iaReason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iaResume": { + "name": "iaResume", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "veille_items_id": { + "name": "veille_items_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "veille_items_dedupKey_unique": { + "name": "veille_items_dedupKey_unique", + "columns": [ + "dedupKey" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d15b4bc..71d5ec9 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 22e7b77..e2be5a2 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -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", { diff --git a/server/aiClassifier.test.ts b/server/aiClassifier.test.ts index 34cad47..5f60ea3 100644 --- a/server/aiClassifier.test.ts +++ b/server/aiClassifier.test.ts @@ -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 () => { diff --git a/server/aiClassifier.ts b/server/aiClassifier.ts index b83f791..e810eb7 100644 --- a/server/aiClassifier.ts +++ b/server/aiClassifier.ts @@ -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, }; } } diff --git a/server/db.ts b/server/db.ts index d7105c6..6d7f5e0 100644 --- a/server/db.ts +++ b/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 { + 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) { diff --git a/server/routers.ts b/server/routers.ts index 0fae02c..1ac38d6 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -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({ diff --git a/server/rssEngine.ts b/server/rssEngine.ts index 3fc842c..3e8062e 100644 --- a/server/rssEngine.ts +++ b/server/rssEngine.ts @@ -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 { }, 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 { (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"; diff --git a/server/veille.test.ts b/server/veille.test.ts index 18592ca..448a6f7 100644 --- a/server/veille.test.ts +++ b/server/veille.test.ts @@ -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 ─────────────────────────────────────────────────────── diff --git a/todo.md b/todo.md index 869f619..717a962 100644 --- a/todo.md +++ b/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.