Checkpoint: Simplification de la classification IA dans l'onglet Veille Stratégique : suppression de la catégorie médico-sociale (Handicap, PA, Enfance, etc.), remplacement de "Générale" par "Informationnelle", classification IA réduite au seul type de veille (réglementaire/concurrentielle/technologique/informationnelle). Migration BDD des données generale → informationnelle. 0 erreur TypeScript, 21 tests passés.
This commit is contained in:
8
.manus/db/db-query-error-1781683149718.json
Normal file
8
.manus/db/db-query-error-1781683149718.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"query": "UPDATE veille_items SET typeVeille = 'informationnelle' WHERE typeVeille = 'generale';\nUPDATE rss_feeds SET defaultTypeVeille = 'informationnelle' WHERE defaultTypeVeille = 'generale';",
|
||||||
|
"command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway02.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 4CrrYuB5tme73Qo.63b125a8f9ca --database VepzDyqR8YkJNcqpZ729Bw --execute UPDATE veille_items SET typeVeille = 'informationnelle' WHERE typeVeille = 'generale';\nUPDATE rss_feeds SET defaultTypeVeille = 'informationnelle' WHERE defaultTypeVeille = 'generale';",
|
||||||
|
"returncode": 1,
|
||||||
|
"logs": [
|
||||||
|
"ERROR 1265 (01000) at line 1: Data truncated for column '%s' at row %d"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": "b19031d9",
|
"version": "7708bf54",
|
||||||
"timestamp": 1781681307542
|
"timestamp": 1781683337686
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ import { cn } from "@/lib/utils";
|
|||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type FeedType = "veille" | "aap";
|
type FeedType = "veille" | "aap";
|
||||||
type TypeVeille = "reglementaire" | "concurrentielle" | "technologique" | "generale";
|
type TypeVeille = "reglementaire" | "concurrentielle" | "technologique" | "informationnelle";
|
||||||
type CategorieAap = "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
|
type CategorieAap = "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
|
||||||
|
|
||||||
interface AutoRule {
|
interface AutoRule {
|
||||||
@@ -89,11 +89,11 @@ const TYPE_VEILLE_LABELS: Record<TypeVeille, string> = {
|
|||||||
reglementaire: "Réglementaire",
|
reglementaire: "Réglementaire",
|
||||||
concurrentielle: "Concurrentielle",
|
concurrentielle: "Concurrentielle",
|
||||||
technologique: "Technologique",
|
technologique: "Technologique",
|
||||||
generale: "Générale",
|
informationnelle: "Informationnelle",
|
||||||
};
|
};
|
||||||
|
|
||||||
const CATEGORIE_AAP_OPTIONS: CategorieAap[] = ["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"];
|
const CATEGORIE_AAP_OPTIONS: CategorieAap[] = ["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"];
|
||||||
const TYPE_VEILLE_OPTIONS: TypeVeille[] = ["reglementaire", "concurrentielle", "technologique", "generale"];
|
const TYPE_VEILLE_OPTIONS: TypeVeille[] = ["reglementaire", "concurrentielle", "technologique", "informationnelle"];
|
||||||
|
|
||||||
const INTERVAL_OPTIONS = [
|
const INTERVAL_OPTIONS = [
|
||||||
{ value: 30, label: "30 minutes" },
|
{ value: 30, label: "30 minutes" },
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
import { fr } from "date-fns/locale";
|
import { fr } from "date-fns/locale";
|
||||||
|
|
||||||
type TypeVeille = "reglementaire" | "concurrentielle" | "technologique" | "generale";
|
type TypeVeille = "reglementaire" | "concurrentielle" | "technologique" | "informationnelle";
|
||||||
|
|
||||||
interface VeilleItem {
|
interface VeilleItem {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -62,7 +62,6 @@ interface VeilleItem {
|
|||||||
datePublication: Date | null;
|
datePublication: Date | null;
|
||||||
importedAt: Date;
|
importedAt: Date;
|
||||||
iaRelevant: boolean | null;
|
iaRelevant: boolean | null;
|
||||||
iaCategorie: string | null;
|
|
||||||
iaClassifiedBy: "ia" | "rules" | null;
|
iaClassifiedBy: "ia" | "rules" | null;
|
||||||
iaReason: string | null;
|
iaReason: string | null;
|
||||||
iaResume: string | null;
|
iaResume: string | null;
|
||||||
@@ -72,28 +71,28 @@ const TYPE_LABELS: Record<TypeVeille, string> = {
|
|||||||
reglementaire: "Réglementaire",
|
reglementaire: "Réglementaire",
|
||||||
concurrentielle: "Concurrentielle",
|
concurrentielle: "Concurrentielle",
|
||||||
technologique: "Technologique",
|
technologique: "Technologique",
|
||||||
generale: "Générale",
|
informationnelle: "Informationnelle",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_COLORS: Record<string, string> = {
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
reglementaire: "bg-blue-100 text-blue-800 border-blue-200",
|
reglementaire: "bg-blue-100 text-blue-800 border-blue-200",
|
||||||
concurrentielle: "bg-purple-100 text-purple-800 border-purple-200",
|
concurrentielle: "bg-purple-100 text-purple-800 border-purple-200",
|
||||||
technologique: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
technologique: "bg-emerald-100 text-emerald-800 border-emerald-200",
|
||||||
generale: "bg-amber-100 text-amber-800 border-amber-200",
|
informationnelle: "bg-amber-100 text-amber-800 border-amber-200",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_ACCENT: Record<string, string> = {
|
const TYPE_ACCENT: Record<string, string> = {
|
||||||
reglementaire: "border-l-blue-500",
|
reglementaire: "border-l-blue-500",
|
||||||
concurrentielle: "border-l-purple-500",
|
concurrentielle: "border-l-purple-500",
|
||||||
technologique: "border-l-emerald-500",
|
technologique: "border-l-emerald-500",
|
||||||
generale: "border-l-amber-500",
|
informationnelle: "border-l-amber-500",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TYPE_HEADER_BG: Record<string, string> = {
|
const TYPE_HEADER_BG: Record<string, string> = {
|
||||||
reglementaire: "from-blue-50 to-blue-100/30 border-blue-200",
|
reglementaire: "from-blue-50 to-blue-100/30 border-blue-200",
|
||||||
concurrentielle: "from-purple-50 to-purple-100/30 border-purple-200",
|
concurrentielle: "from-purple-50 to-purple-100/30 border-purple-200",
|
||||||
technologique: "from-emerald-50 to-emerald-100/30 border-emerald-200",
|
technologique: "from-emerald-50 to-emerald-100/30 border-emerald-200",
|
||||||
generale: "from-amber-50 to-amber-100/30 border-amber-200",
|
informationnelle: "from-amber-50 to-amber-100/30 border-amber-200",
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 24;
|
const PAGE_SIZE = 24;
|
||||||
@@ -151,15 +150,7 @@ function VeilleDetailDialog({
|
|||||||
|
|
||||||
{/* Métadonnées en grille */}
|
{/* Métadonnées en grille */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
{item.categorie && (
|
|
||||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-muted/40 border border-border/50">
|
|
||||||
<Tag size={14} className="text-muted-foreground mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground font-medium">Catégorie</p>
|
|
||||||
<p className="text-sm font-medium text-foreground">{item.categorie}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{item.niveau && (
|
{item.niveau && (
|
||||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-violet-50 border border-violet-200">
|
<div className="flex items-start gap-2 p-3 rounded-lg bg-violet-50 border border-violet-200">
|
||||||
<Layers size={14} className="text-violet-500 mt-0.5 shrink-0" />
|
<Layers size={14} className="text-violet-500 mt-0.5 shrink-0" />
|
||||||
@@ -314,7 +305,6 @@ export default function VeilleDashboard() {
|
|||||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
|
||||||
const filterOptions = [
|
const filterOptions = [
|
||||||
{ key: "categorie", label: "Catégorie", options: filtersQuery.data?.categories ?? [] },
|
|
||||||
{ key: "niveau", label: "Niveau", options: filtersQuery.data?.niveaux ?? [] },
|
{ key: "niveau", label: "Niveau", options: filtersQuery.data?.niveaux ?? [] },
|
||||||
{ key: "territoire", label: "Territoire", options: filtersQuery.data?.territoires ?? [] },
|
{ key: "territoire", label: "Territoire", options: filtersQuery.data?.territoires ?? [] },
|
||||||
{ key: "dateFrom", label: "Date depuis", type: "date" as const },
|
{ key: "dateFrom", label: "Date depuis", type: "date" as const },
|
||||||
@@ -334,7 +324,7 @@ export default function VeilleDashboard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
Suivi réglementaire, concurrentiel, technologique et général
|
Suivi réglementaire, concurrentiel, technologique et informationnel
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -460,7 +450,6 @@ function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
|
|||||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-8">#</th>
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-8">#</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Titre</th>
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground">Titre</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-32">Type</th>
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-32">Type</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-28">Catégorie</th>
|
|
||||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-24">Niveau</th>
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-24">Niveau</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-36">Territoire</th>
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-36">Territoire</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-28">Date</th>
|
<th className="text-left px-4 py-3 font-semibold text-muted-foreground w-28">Date</th>
|
||||||
@@ -487,9 +476,6 @@ function VeilleListView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
|
|||||||
{TYPE_LABELS[item.typeVeille as TypeVeille] || item.typeVeille}
|
{TYPE_LABELS[item.typeVeille as TypeVeille] || item.typeVeille}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-xs">
|
|
||||||
<span className="text-muted-foreground">{item.categorie || "—"}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-muted-foreground text-xs">{item.niveau || "—"}</td>
|
<td className="px-4 py-3 text-muted-foreground text-xs">{item.niveau || "—"}</td>
|
||||||
<td className="px-4 py-3 text-muted-foreground text-xs">{item.territoire || "—"}</td>
|
<td className="px-4 py-3 text-muted-foreground text-xs">{item.territoire || "—"}</td>
|
||||||
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">{formatDate(item.datePublication) || "—"}</td>
|
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap">{formatDate(item.datePublication) || "—"}</td>
|
||||||
@@ -562,8 +548,7 @@ function VeilleGridView({ items, onDetail, readIds }: { items: VeilleItem[]; onD
|
|||||||
<CardContent className="px-4 pb-4 space-y-2">
|
<CardContent className="px-4 pb-4 space-y-2">
|
||||||
{(item.iaResume || item.resume) && <p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">{item.iaResume || item.resume}</p>}
|
{(item.iaResume || item.resume) && <p className="text-xs text-muted-foreground line-clamp-3 leading-relaxed">{item.iaResume || item.resume}</p>}
|
||||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||||
{item.categorie && <span className="inline-flex items-center gap-1 text-xs text-muted-foreground"><Tag size={10} />{item.categorie}</span>}
|
{item.territoire && (
|
||||||
{item.territoire && (
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-teal-50 text-teal-700 border border-teal-200">
|
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-teal-50 text-teal-700 border border-teal-200">
|
||||||
<MapPin size={10} />{item.territoire}
|
<MapPin size={10} />{item.territoire}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
2
drizzle/0009_fat_rocket_racer.sql
Normal file
2
drizzle/0009_fat_rocket_racer.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `rss_feeds` MODIFY COLUMN `defaultTypeVeille` enum('reglementaire','concurrentielle','technologique','informationnelle');--> statement-breakpoint
|
||||||
|
ALTER TABLE `veille_items` MODIFY COLUMN `typeVeille` enum('reglementaire','concurrentielle','technologique','informationnelle') NOT NULL;
|
||||||
985
drizzle/meta/0009_snapshot.json
Normal file
985
drizzle/meta/0009_snapshot.json
Normal file
@@ -0,0 +1,985 @@
|
|||||||
|
{
|
||||||
|
"version": "5",
|
||||||
|
"dialect": "mysql",
|
||||||
|
"id": "60818888-9a29-4da2-b430-ea765431d740",
|
||||||
|
"prevId": "88852e92-af9f-4778-8015-3c6318f59cb4",
|
||||||
|
"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": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {
|
||||||
|
"article_reads_id": {
|
||||||
|
"name": "article_reads_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
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,13 @@
|
|||||||
"when": 1781593251405,
|
"when": 1781593251405,
|
||||||
"tag": "0008_worried_molten_man",
|
"tag": "0008_worried_molten_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1781683138994,
|
||||||
|
"tag": "0009_fat_rocket_racer",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ export const veilleItems = mysqlTable("veille_items", {
|
|||||||
passage: text("passage"),
|
passage: text("passage"),
|
||||||
lien: text("lien"),
|
lien: text("lien"),
|
||||||
// Type de veille (feuille d'origine)
|
// Type de veille (feuille d'origine)
|
||||||
typeVeille: mysqlEnum("typeVeille", ["reglementaire", "concurrentielle", "technologique", "generale"]).notNull(),
|
typeVeille: mysqlEnum("typeVeille", ["reglementaire", "concurrentielle", "technologique", "informationnelle"]).notNull(),
|
||||||
// Date extraite de la colonne Source (qui contient parfois une date ISO)
|
// Date extraite de la colonne Source (qui contient parfois une date ISO)
|
||||||
datePublication: timestamp("datePublication"),
|
datePublication: timestamp("datePublication"),
|
||||||
importedAt: timestamp("importedAt").defaultNow().notNull(),
|
importedAt: timestamp("importedAt").defaultNow().notNull(),
|
||||||
@@ -165,7 +165,7 @@ export const rssFeeds = mysqlTable("rss_feeds", {
|
|||||||
// Type de contenu alimenté par ce flux
|
// Type de contenu alimenté par ce flux
|
||||||
feedType: mysqlEnum("feedType", ["veille", "aap"]).notNull(),
|
feedType: mysqlEnum("feedType", ["veille", "aap"]).notNull(),
|
||||||
// Pour les flux de type veille : type de veille par défaut
|
// Pour les flux de type veille : type de veille par défaut
|
||||||
defaultTypeVeille: mysqlEnum("defaultTypeVeille", ["reglementaire", "concurrentielle", "technologique", "generale"]),
|
defaultTypeVeille: mysqlEnum("defaultTypeVeille", ["reglementaire", "concurrentielle", "technologique", "informationnelle"]),
|
||||||
// Pour les flux de type aap : catégorie par défaut
|
// Pour les flux de type aap : catégorie par défaut
|
||||||
defaultCategorieAap: mysqlEnum("defaultCategorieAap", ["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]),
|
defaultCategorieAap: mysqlEnum("defaultCategorieAap", ["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]),
|
||||||
// Règles d'automatisme JSON : [{keyword, typeVeille|categorieAap}]
|
// Règles d'automatisme JSON : [{keyword, typeVeille|categorieAap}]
|
||||||
|
|||||||
88
scripts/migrate-generale-to-informationnelle.mjs
Normal file
88
scripts/migrate-generale-to-informationnelle.mjs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Script de migration : generale → informationnelle
|
||||||
|
*
|
||||||
|
* Étapes :
|
||||||
|
* 1. Élargir l'enum typeVeille pour inclure 'informationnelle' (en plus de 'generale')
|
||||||
|
* 2. Mettre à jour les données existantes (generale → informationnelle)
|
||||||
|
* 3. Réduire l'enum pour supprimer 'generale'
|
||||||
|
* 4. Même chose pour defaultTypeVeille dans rss_feeds
|
||||||
|
*/
|
||||||
|
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { config } from "dotenv";
|
||||||
|
|
||||||
|
config();
|
||||||
|
|
||||||
|
const DATABASE_URL = process.env.DATABASE_URL;
|
||||||
|
if (!DATABASE_URL) {
|
||||||
|
console.error("DATABASE_URL non définie");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraire les paramètres de connexion depuis l'URL
|
||||||
|
const url = new URL(DATABASE_URL);
|
||||||
|
const sslParam = url.searchParams.get("ssl");
|
||||||
|
const sslConfig = sslParam ? JSON.parse(sslParam) : undefined;
|
||||||
|
|
||||||
|
const connection = await mysql.createConnection({
|
||||||
|
host: url.hostname,
|
||||||
|
port: parseInt(url.port || "3306"),
|
||||||
|
user: url.username,
|
||||||
|
password: url.password,
|
||||||
|
database: url.pathname.slice(1),
|
||||||
|
ssl: sslConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Connexion BDD établie");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Étape 1 : Élargir l'enum veille_items.typeVeille
|
||||||
|
console.log("1. Élargissement de l'enum veille_items.typeVeille...");
|
||||||
|
await connection.execute(
|
||||||
|
`ALTER TABLE veille_items MODIFY COLUMN typeVeille ENUM('reglementaire','concurrentielle','technologique','generale','informationnelle') NOT NULL`
|
||||||
|
);
|
||||||
|
console.log(" ✓ Enum élargi");
|
||||||
|
|
||||||
|
// Étape 2 : Migrer les données veille_items
|
||||||
|
console.log("2. Migration des données veille_items (generale → informationnelle)...");
|
||||||
|
const [result1] = await connection.execute(
|
||||||
|
`UPDATE veille_items SET typeVeille = 'informationnelle' WHERE typeVeille = 'generale'`
|
||||||
|
);
|
||||||
|
console.log(` ✓ ${result1.affectedRows} lignes mises à jour`);
|
||||||
|
|
||||||
|
// Étape 3 : Réduire l'enum veille_items.typeVeille (supprimer 'generale')
|
||||||
|
console.log("3. Réduction de l'enum veille_items.typeVeille (suppression de 'generale')...");
|
||||||
|
await connection.execute(
|
||||||
|
`ALTER TABLE veille_items MODIFY COLUMN typeVeille ENUM('reglementaire','concurrentielle','technologique','informationnelle') NOT NULL`
|
||||||
|
);
|
||||||
|
console.log(" ✓ Enum réduit");
|
||||||
|
|
||||||
|
// Étape 4 : Élargir l'enum rss_feeds.defaultTypeVeille
|
||||||
|
console.log("4. Élargissement de l'enum rss_feeds.defaultTypeVeille...");
|
||||||
|
await connection.execute(
|
||||||
|
`ALTER TABLE rss_feeds MODIFY COLUMN defaultTypeVeille ENUM('reglementaire','concurrentielle','technologique','generale','informationnelle')`
|
||||||
|
);
|
||||||
|
console.log(" ✓ Enum élargi");
|
||||||
|
|
||||||
|
// Étape 5 : Migrer les données rss_feeds
|
||||||
|
console.log("5. Migration des données rss_feeds (generale → informationnelle)...");
|
||||||
|
const [result2] = await connection.execute(
|
||||||
|
`UPDATE rss_feeds SET defaultTypeVeille = 'informationnelle' WHERE defaultTypeVeille = 'generale'`
|
||||||
|
);
|
||||||
|
console.log(` ✓ ${result2.affectedRows} lignes mises à jour`);
|
||||||
|
|
||||||
|
// Étape 6 : Réduire l'enum rss_feeds.defaultTypeVeille (supprimer 'generale')
|
||||||
|
console.log("6. Réduction de l'enum rss_feeds.defaultTypeVeille (suppression de 'generale')...");
|
||||||
|
await connection.execute(
|
||||||
|
`ALTER TABLE rss_feeds MODIFY COLUMN defaultTypeVeille ENUM('reglementaire','concurrentielle','technologique','informationnelle')`
|
||||||
|
);
|
||||||
|
console.log(" ✓ Enum réduit");
|
||||||
|
|
||||||
|
console.log("\n✅ Migration terminée avec succès !");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Erreur de migration :", err.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
await connection.end();
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
* Ces tests vérifient le comportement du module de classification IA :
|
* Ces tests vérifient le comportement du module de classification IA :
|
||||||
* - Fallback sur les règles en cas d'erreur LLM
|
* - Fallback sur les règles en cas d'erreur LLM
|
||||||
* - Résultat "hors périmètre" quand pertinent = false
|
* - Résultat "hors périmètre" quand pertinent = false
|
||||||
* - Classification correcte quand les deux étapes réussissent
|
* - Classification correcte en une seule étape (pertinence + type de veille)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
@@ -37,8 +37,7 @@ function makeLLMResponse(content: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fallbackFn = () => ({
|
const fallbackFn = () => ({
|
||||||
categorie: "Autre" as const,
|
typeVeille: "informationnelle" as const,
|
||||||
typeVeille: "generale" as const,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||||
@@ -48,28 +47,33 @@ describe("classifyArticle", () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retourne le fallback (rules) si le LLM échoue à l'étape 1 (pertinence)", async () => {
|
it("retourne le fallback (rules) si le LLM échoue", async () => {
|
||||||
mockInvokeLLM.mockRejectedValueOnce(new Error("LLM timeout"));
|
mockInvokeLLM.mockRejectedValueOnce(new Error("LLM timeout"));
|
||||||
|
|
||||||
const result = await classifyArticle("Titre test", "Résumé test", fallbackFn);
|
const result = await classifyArticle("Titre test", "Résumé test", fallbackFn);
|
||||||
|
|
||||||
expect(result.classifiedBy).toBe("rules");
|
expect(result.classifiedBy).toBe("rules");
|
||||||
expect(result.categorie).toBe("Autre");
|
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");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retourne le fallback (rules) si le LLM retourne un JSON malformé à l'étape 1", async () => {
|
it("retourne le fallback (rules) si le LLM retourne un JSON malformé", async () => {
|
||||||
mockInvokeLLM.mockResolvedValueOnce(makeLLMResponse("pas du JSON valide ici"));
|
mockInvokeLLM.mockResolvedValueOnce(makeLLMResponse("pas du JSON valide ici"));
|
||||||
|
|
||||||
const result = await classifyArticle("Titre test", "Résumé test", fallbackFn);
|
const result = await classifyArticle("Titre test", "Résumé test", fallbackFn);
|
||||||
|
|
||||||
expect(result.classifiedBy).toBe("rules");
|
expect(result.classifiedBy).toBe("rules");
|
||||||
expect(result.categorie).toBe("Autre");
|
expect(result.typeVeille).toBe("informationnelle");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retourne relevant=false et categorie=Autre si l'article est hors périmètre", async () => {
|
it("retourne relevant=false et typeVeille=null si l'article est hors périmètre", async () => {
|
||||||
mockInvokeLLM.mockResolvedValueOnce(
|
mockInvokeLLM.mockResolvedValueOnce(
|
||||||
makeLLMResponse(JSON.stringify({ pertinent: false, raison: "Actualité politique générale sans lien médico-social" }))
|
makeLLMResponse(JSON.stringify({
|
||||||
|
pertinent: false,
|
||||||
|
typeVeille: null,
|
||||||
|
raison: "Actualité politique générale sans lien médico-social",
|
||||||
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await classifyArticle(
|
const result = await classifyArticle(
|
||||||
@@ -79,25 +83,18 @@ describe("classifyArticle", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result.relevant).toBe(false);
|
expect(result.relevant).toBe(false);
|
||||||
expect(result.categorie).toBe("Autre");
|
expect(result.typeVeille).toBeNull();
|
||||||
expect(result.classifiedBy).toBe("ia");
|
expect(result.classifiedBy).toBe("ia");
|
||||||
expect(result.reason).toContain("politique");
|
expect(result.reason).toContain("politique");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("classifie correctement un article pertinent en deux étapes", async () => {
|
it("classifie correctement un article pertinent réglementaire en une seule étape", async () => {
|
||||||
// Étape 1 : pertinent = true
|
|
||||||
mockInvokeLLM.mockResolvedValueOnce(
|
mockInvokeLLM.mockResolvedValueOnce(
|
||||||
makeLLMResponse(JSON.stringify({ pertinent: true, raison: "Concerne les EHPAD et la perte d'autonomie" }))
|
makeLLMResponse(JSON.stringify({
|
||||||
);
|
pertinent: true,
|
||||||
// Étape 2 : classification
|
typeVeille: "reglementaire",
|
||||||
mockInvokeLLM.mockResolvedValueOnce(
|
raison: "Décret relatif au financement des EHPAD",
|
||||||
makeLLMResponse(
|
}))
|
||||||
JSON.stringify({
|
|
||||||
categorie: "PA",
|
|
||||||
typeVeille: "reglementaire",
|
|
||||||
raison: "Décret relatif au financement des EHPAD",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await classifyArticle(
|
const result = await classifyArticle(
|
||||||
@@ -107,50 +104,33 @@ describe("classifyArticle", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(result.relevant).toBe(true);
|
expect(result.relevant).toBe(true);
|
||||||
expect(result.categorie).toBe("PA");
|
|
||||||
expect(result.typeVeille).toBe("reglementaire");
|
expect(result.typeVeille).toBe("reglementaire");
|
||||||
expect(result.classifiedBy).toBe("ia");
|
expect(result.classifiedBy).toBe("ia");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("retourne le fallback (rules) si l'étape 2 (classification) échoue", async () => {
|
|
||||||
// Étape 1 : pertinent = true
|
|
||||||
mockInvokeLLM.mockResolvedValueOnce(
|
|
||||||
makeLLMResponse(JSON.stringify({ pertinent: true, raison: "Article pertinent" }))
|
|
||||||
);
|
|
||||||
// Étape 2 : erreur LLM
|
|
||||||
mockInvokeLLM.mockRejectedValueOnce(new Error("LLM quota exceeded"));
|
|
||||||
|
|
||||||
const result = await classifyArticle("Titre pertinent", "Résumé pertinent", fallbackFn);
|
|
||||||
|
|
||||||
expect(result.classifiedBy).toBe("rules");
|
|
||||||
expect(result.relevant).toBe(true);
|
|
||||||
expect(result.reason).toContain("Pertinent");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepte les réponses LLM avec balises markdown ```json```", async () => {
|
it("accepte les réponses LLM avec balises markdown ```json```", async () => {
|
||||||
// Étape 1 avec balises markdown
|
|
||||||
mockInvokeLLM.mockResolvedValueOnce(
|
mockInvokeLLM.mockResolvedValueOnce(
|
||||||
makeLLMResponse("```json\n" + JSON.stringify({ pertinent: false, raison: "Hors périmètre" }) + "\n```")
|
makeLLMResponse("```json\n" + JSON.stringify({
|
||||||
|
pertinent: false,
|
||||||
|
typeVeille: null,
|
||||||
|
raison: "Hors périmètre",
|
||||||
|
}) + "\n```")
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await classifyArticle("Titre sport", "Résultats du championnat de football", fallbackFn);
|
const result = await classifyArticle("Titre sport", "Résultats du championnat de football", fallbackFn);
|
||||||
|
|
||||||
expect(result.relevant).toBe(false);
|
expect(result.relevant).toBe(false);
|
||||||
|
expect(result.typeVeille).toBeNull();
|
||||||
expect(result.classifiedBy).toBe("ia");
|
expect(result.classifiedBy).toBe("ia");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("classe un article handicap correctement", async () => {
|
it("classe un article handicap comme réglementaire", async () => {
|
||||||
mockInvokeLLM.mockResolvedValueOnce(
|
mockInvokeLLM.mockResolvedValueOnce(
|
||||||
makeLLMResponse(JSON.stringify({ pertinent: true, raison: "Concerne les IME et l'inclusion scolaire" }))
|
makeLLMResponse(JSON.stringify({
|
||||||
);
|
pertinent: true,
|
||||||
mockInvokeLLM.mockResolvedValueOnce(
|
typeVeille: "reglementaire",
|
||||||
makeLLMResponse(
|
raison: "Circulaire sur l'inclusion scolaire des enfants en situation de handicap",
|
||||||
JSON.stringify({
|
}))
|
||||||
categorie: "Handicap",
|
|
||||||
typeVeille: "reglementaire",
|
|
||||||
raison: "Circulaire sur l'inclusion scolaire des enfants en situation de handicap",
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await classifyArticle(
|
const result = await classifyArticle(
|
||||||
@@ -159,8 +139,48 @@ describe("classifyArticle", () => {
|
|||||||
fallbackFn
|
fallbackFn
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result.categorie).toBe("Handicap");
|
expect(result.relevant).toBe(true);
|
||||||
expect(result.typeVeille).toBe("reglementaire");
|
expect(result.typeVeille).toBe("reglementaire");
|
||||||
expect(result.classifiedBy).toBe("ia");
|
expect(result.classifiedBy).toBe("ia");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("classe un article technologique correctement", async () => {
|
||||||
|
mockInvokeLLM.mockResolvedValueOnce(
|
||||||
|
makeLLMResponse(JSON.stringify({
|
||||||
|
pertinent: true,
|
||||||
|
typeVeille: "technologique",
|
||||||
|
raison: "Déploiement d'un nouveau logiciel de gestion pour les ESMS",
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await classifyArticle(
|
||||||
|
"Nouveau logiciel de gestion pour les établissements médico-sociaux",
|
||||||
|
"Un éditeur lance une solution numérique dédiée à la gestion des ESMS.",
|
||||||
|
fallbackFn
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.relevant).toBe(true);
|
||||||
|
expect(result.typeVeille).toBe("technologique");
|
||||||
|
expect(result.classifiedBy).toBe("ia");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("utilise informationnelle comme typeVeille par défaut si pertinent mais typeVeille null", async () => {
|
||||||
|
mockInvokeLLM.mockResolvedValueOnce(
|
||||||
|
makeLLMResponse(JSON.stringify({
|
||||||
|
pertinent: true,
|
||||||
|
typeVeille: null,
|
||||||
|
raison: "Article pertinent mais type non déterminé",
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await classifyArticle(
|
||||||
|
"Rapport annuel sur le secteur médico-social",
|
||||||
|
"Publication du rapport annuel de la CNSA sur l'état du secteur.",
|
||||||
|
fallbackFn
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.relevant).toBe(true);
|
||||||
|
expect(result.typeVeille).toBe("informationnelle"); // fallback sur informationnelle
|
||||||
|
expect(result.classifiedBy).toBe("ia");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Classification IA des articles RSS en deux étapes :
|
* Classification IA des articles RSS en une seule étape :
|
||||||
*
|
*
|
||||||
* Étape 1 — Filtre de pertinence :
|
* Le LLM détermine simultanément :
|
||||||
* Détermine si l'article est pertinent pour le secteur médico-social
|
* 1. Si l'article est pertinent pour le secteur médico-social
|
||||||
* (handicap, personnes âgées, protection de l'enfance, précarité, sanitaire).
|
* 2. Le type de veille (réglementaire, concurrentielle, technologique, informationnelle)
|
||||||
*
|
|
||||||
* Étape 2 — Classification par catégorie :
|
|
||||||
* Si l'article est pertinent, le classe dans l'une des catégories du secteur.
|
|
||||||
*
|
*
|
||||||
* En cas d'erreur LLM (timeout, quota, JSON malformé), le module retourne
|
* En cas d'erreur LLM (timeout, quota, JSON malformé), le module retourne
|
||||||
* classifiedBy = "rules" pour signaler que le fallback doit être utilisé.
|
* classifiedBy = "rules" pour signaler que le fallback doit être utilisé.
|
||||||
@@ -16,38 +13,30 @@ import { invokeLLM } from "./_core/llm";
|
|||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export type AiCategorie =
|
|
||||||
| "Handicap"
|
|
||||||
| "PA"
|
|
||||||
| "Enfance"
|
|
||||||
| "Précarité"
|
|
||||||
| "Sanitaire"
|
|
||||||
| "Autre";
|
|
||||||
|
|
||||||
export type AiTypeVeille =
|
export type AiTypeVeille =
|
||||||
| "reglementaire"
|
| "reglementaire"
|
||||||
| "concurrentielle"
|
| "concurrentielle"
|
||||||
| "technologique"
|
| "technologique"
|
||||||
| "generale";
|
| "informationnelle";
|
||||||
|
|
||||||
export interface AiClassificationResult {
|
export interface AiClassificationResult {
|
||||||
/** L'article est-il pertinent pour le secteur médico-social ? */
|
/** L'article est-il pertinent pour le secteur médico-social ? */
|
||||||
relevant: boolean;
|
relevant: boolean;
|
||||||
/** Catégorie principale déduite par l'IA */
|
/** Type de veille déduit par l'IA (null si non pertinent) */
|
||||||
categorie: AiCategorie;
|
typeVeille: AiTypeVeille | null;
|
||||||
/** Type de veille déduit par l'IA (uniquement pour les articles de veille) */
|
|
||||||
typeVeille?: AiTypeVeille;
|
|
||||||
/** Explication courte de la décision */
|
/** Explication courte de la décision */
|
||||||
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";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Prompt 1 : Filtre de pertinence ─────────────────────────────────────────
|
// ─── Prompt unique : pertinence + type de veille ─────────────────────────────
|
||||||
|
|
||||||
const PROMPT_PERTINENCE = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS) en France, spécialisé dans les secteurs : handicap, personnes âgées, protection de l'enfance, précarité/insertion, sanitaire.
|
const PROMPT_CLASSIFICATION = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS) en France, spécialisé dans les secteurs : handicap, personnes âgées, protection de l'enfance, précarité/insertion, sanitaire.
|
||||||
|
|
||||||
Ta mission est de déterminer si un article de presse ou une publication institutionnelle est DIRECTEMENT pertinent pour une association gestionnaire d'ESMS.
|
Ta mission est d'analyser un article et de déterminer :
|
||||||
|
1. S'il est DIRECTEMENT pertinent pour une association gestionnaire d'ESMS
|
||||||
|
2. Si oui, à quel type de veille il appartient
|
||||||
|
|
||||||
Un article est pertinent UNIQUEMENT s'il traite explicitement de l'un des sujets suivants :
|
Un article est pertinent UNIQUEMENT s'il traite explicitement de l'un des sujets suivants :
|
||||||
- Réglementation, lois, décrets, circulaires concernant les ESMS, le secteur social ou médico-social
|
- Réglementation, lois, décrets, circulaires concernant les ESMS, le secteur social ou médico-social
|
||||||
@@ -59,50 +48,43 @@ Un article est pertinent UNIQUEMENT s'il traite explicitement de l'un des sujets
|
|||||||
- Santé publique avec impact direct sur les ESMS ou leurs usagers
|
- Santé publique avec impact direct sur les ESMS ou leurs usagers
|
||||||
|
|
||||||
Un article est NON PERTINENT si :
|
Un article est NON PERTINENT si :
|
||||||
- Il traite de politique générale, économie, international, environnement, sport, culture, agriculture, pêche, tourisme, immobilier, technologie grand public, etc.
|
- Il traite de politique générale, économie, international, environnement, sport, culture, agriculture, pêche, tourisme, immobilier, technologie grand public, rénovation énergétique, etc.
|
||||||
- Il mentionne des personnes vulnérables de façon anecdotique sans lien avec les ESMS
|
- Il mentionne des personnes vulnérables de façon anecdotique sans lien avec les ESMS
|
||||||
- Il concerne la santé uniquement sous l'angle hospitalier ou médical sans lien avec le médico-social
|
- Il concerne la santé uniquement sous l'angle hospitalier ou médical sans lien avec le médico-social
|
||||||
- Le lien avec le secteur médico-social est vague, indirect ou nécessite plusieurs degrés de déduction
|
- Le lien avec le secteur médico-social est vague, indirect ou nécessite plusieurs degrés de déduction
|
||||||
|
|
||||||
Sois STRICT : en cas de doute, réponds pertinent: false.
|
Sois STRICT : en cas de doute, réponds pertinent: false.
|
||||||
|
|
||||||
Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, sans balises markdown :
|
Si l'article est pertinent, classe-le dans l'un des types de veille suivants :
|
||||||
{"pertinent": true/false, "raison": "explication courte en une phrase"}`;
|
- "reglementaire" : textes de loi, décrets, circulaires, obligations légales, réformes institutionnelles, instructions ministérielles
|
||||||
|
- "concurrentielle" : actualités d'autres associations ou structures, appels d'offres, marchés, positionnement sectoriel, benchmarks
|
||||||
// ─── Prompt 2 : Classification par catégorie ─────────────────────────────────
|
- "technologique" : innovations numériques, outils, systèmes d'information, nouvelles pratiques professionnelles, recherche appliquée
|
||||||
|
- "informationnelle" : informations générales pertinentes pour le secteur sans caractère réglementaire, concurrentiel ou technologique fort (rapports, études, événements sectoriels, nominations, etc.)
|
||||||
const PROMPT_CLASSIFICATION = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS) évoluant dans les secteurs suivants: handicap, personnes âgées, protection de l'enfance, précarité, sanitaire. Analyse le texte fourni et classe-le dans un seul secteur principal en te basant uniquement sur le public concerné, la structure mentionnée, l'objectif principal et le cadre institutionnel éventuel (ARS, Département, Justice, etc.).
|
|
||||||
|
|
||||||
Handicap : choisir cette catégorie si le public principal est une personne en situation de handicap (moteur, psychique, intellectuel, sensoriel, TSA, polyhandicap, etc.). Inclut les structures et dispositifs tels que IME, ITEP, ESAT, MAS, FAM, SAVS, SAMSAH, SESSAD, MDPH, PCH, SERAFIN-PH, inclusion et compensation du handicap. Même si des soins sont évoqués, rester dans cette catégorie si la structure principale est médico-sociale liée au handicap.
|
|
||||||
|
|
||||||
Personnes âgées : choisir cette catégorie si le sujet principal concerne le vieillissement ou la perte d'autonomie. Inclut EHPAD, SSIAD, SPASAD, résidences autonomie, GIR, APA, maintien à domicile, dépendance, maladies neurodégénératives. Si le sujet porte principalement sur l'hôpital ou l'organisation des soins hospitaliers, choisir sanitaire.
|
|
||||||
|
|
||||||
Protection de l'enfance : choisir cette catégorie si le texte concerne des mineurs en danger ou protégés. Inclut ASE, MECS, AEMO, placement familial, protection judiciaire, MNA, mesures administratives ou judiciaires de protection. Si la situation concerne la pauvreté familiale sans mesure de protection, choisir précarité/insertion.
|
|
||||||
|
|
||||||
Précarité / Insertion : choisir cette catégorie si le sujet concerne l'exclusion sociale, l'hébergement, l'accès aux droits ou l'insertion professionnelle. Inclut CHRS, hébergement d'urgence, RSA, insertion sociale ou professionnelle, logement accompagné, accompagnement des publics migrants. Si une mesure judiciaire protège un mineur, choisir protection de l'enfance.
|
|
||||||
|
|
||||||
Sanitaire : choisir cette catégorie si le sujet relève principalement du soin médical, de l'hôpital ou de l'organisation du système de santé. Inclut hôpital, parcours de soins, CPTS, psychiatrie hospitalière, santé publique, organisation des soins par l'ARS. Si l'article concerne un établissement médico-social avec coordination médicale, choisir le secteur médico-social correspondant plutôt que sanitaire.
|
|
||||||
|
|
||||||
Toujours identifier en priorité le public principal, puis l'objectif central (soin médical, accompagnement social, protection, insertion), puis la structure dominante.
|
|
||||||
|
|
||||||
Si aucune catégorie ne correspond clairement, ou si le texte ne concerne pas le secteur médico-social, répondre "Autre".
|
|
||||||
|
|
||||||
IMPORTANT : Si le texte concerne la pêche, l'agriculture, le sport, la culture, l'environnement, l'économie générale, la politique internationale, ou tout autre domaine sans lien direct avec les ESMS, réponds obligatoirement {"categorie": "Autre", ...}.
|
|
||||||
|
|
||||||
Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, sans balises markdown :
|
Réponds UNIQUEMENT avec un objet JSON valide, sans texte autour, sans balises markdown :
|
||||||
{"categorie": "Handicap"|"PA"|"Enfance"|"Précarité"|"Sanitaire"|"Autre", "typeVeille": "reglementaire"|"concurrentielle"|"technologique"|"generale", "raison": "explication courte en une phrase"}
|
{"pertinent": true/false, "typeVeille": "reglementaire"|"concurrentielle"|"technologique"|"informationnelle"|null, "raison": "explication courte en une phrase"}
|
||||||
|
|
||||||
Pour typeVeille :
|
Si pertinent est false, typeVeille doit être null.`;
|
||||||
- reglementaire : textes de loi, décrets, circulaires, obligations légales, réformes institutionnelles
|
|
||||||
- concurrentielle : actualités d'autres associations, appels d'offres, marchés, positionnement sectoriel
|
// ─── Prompt résumé IA ─────────────────────────────────────────────────────────
|
||||||
- technologique : innovations numériques, outils, systèmes d'information, nouvelles pratiques
|
|
||||||
- generale : tout autre sujet pertinent sans caractère réglementaire, concurrentiel ou technologique fort`;
|
const PROMPT_RESUME = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS).
|
||||||
|
Tu dois rédiger un résumé clair et professionnel de l'article fourni, destiné aux professionnels du secteur médico-social.
|
||||||
|
|
||||||
|
Consignes :
|
||||||
|
- Rédige 3 à 5 phrases en français, en langage clair et accessible
|
||||||
|
- Mets en avant les points clés : qui est concerné, quelle mesure ou information, quel impact pour les structures
|
||||||
|
- Reste factuel et objectif, sans jugement de valeur
|
||||||
|
- N'utilise pas de listes à puces, écris en prose
|
||||||
|
- Ne commence pas par "Cet article" ou "Le texte"
|
||||||
|
- Adapte le vocabulaire au secteur médico-social (ESMS, ARS, MDPH, etc.)
|
||||||
|
|
||||||
|
Réponds UNIQUEMENT avec le texte du résumé, sans introduction ni conclusion.`;
|
||||||
|
|
||||||
// ─── Utilitaire : parse JSON robuste ─────────────────────────────────────────
|
// ─── Utilitaire : parse JSON robuste ─────────────────────────────────────────
|
||||||
|
|
||||||
function parseJsonSafe<T>(text: string): T | null {
|
function parseJsonSafe<T>(text: string): T | null {
|
||||||
try {
|
try {
|
||||||
// Nettoyer les éventuelles balises markdown ```json ... ```
|
|
||||||
const cleaned = text
|
const cleaned = text
|
||||||
.replace(/^```json\s*/i, "")
|
.replace(/^```json\s*/i, "")
|
||||||
.replace(/^```\s*/i, "")
|
.replace(/^```\s*/i, "")
|
||||||
@@ -114,56 +96,22 @@ function parseJsonSafe<T>(text: string): T | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Étape 1 : Filtre de pertinence ──────────────────────────────────────────
|
// ─── Classification principale ────────────────────────────────────────────────
|
||||||
|
|
||||||
async function checkRelevance(
|
/**
|
||||||
|
* Classifie un article en une seule étape via l'IA.
|
||||||
|
* Détermine simultanément la pertinence et le type de veille.
|
||||||
|
* Retourne toujours un résultat (fallback sur rules en cas d'erreur).
|
||||||
|
*
|
||||||
|
* @param titre Titre de l'article
|
||||||
|
* @param resume Résumé / description de l'article
|
||||||
|
* @param fallbackFn Fonction de fallback appelée si l'IA échoue
|
||||||
|
*/
|
||||||
|
export async function classifyArticle(
|
||||||
titre: string,
|
titre: string,
|
||||||
resume: string
|
resume: string,
|
||||||
): Promise<{ pertinent: boolean; raison: string } | null> {
|
fallbackFn: () => { typeVeille: AiTypeVeille }
|
||||||
const userContent = `Titre : ${titre}\n\nRésumé : ${resume}`;
|
): Promise<AiClassificationResult> {
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await invokeLLM({
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: PROMPT_PERTINENCE },
|
|
||||||
{ role: "user", content: userContent },
|
|
||||||
],
|
|
||||||
response_format: {
|
|
||||||
type: "json_schema",
|
|
||||||
json_schema: {
|
|
||||||
name: "pertinence_result",
|
|
||||||
strict: true,
|
|
||||||
schema: {
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
pertinent: { type: "boolean" },
|
|
||||||
raison: { type: "string" },
|
|
||||||
},
|
|
||||||
required: ["pertinent", "raison"],
|
|
||||||
additionalProperties: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const rawContent = response?.choices?.[0]?.message?.content;
|
|
||||||
if (!rawContent) return null;
|
|
||||||
const content = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
|
|
||||||
|
|
||||||
const parsed = parseJsonSafe<{ pertinent: boolean; raison: string }>(content);
|
|
||||||
return parsed;
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[AI Classifier] Erreur étape 1 (pertinence):", (e as Error).message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Étape 2 : Classification par catégorie ───────────────────────────────────
|
|
||||||
|
|
||||||
async function classifyCategory(
|
|
||||||
titre: string,
|
|
||||||
resume: string
|
|
||||||
): Promise<{ categorie: AiCategorie; typeVeille: AiTypeVeille; raison: string } | null> {
|
|
||||||
const userContent = `Titre : ${titre}\n\nRésumé : ${resume}`;
|
const userContent = `Titre : ${titre}\n\nRésumé : ${resume}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -180,17 +128,19 @@ async function classifyCategory(
|
|||||||
schema: {
|
schema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
categorie: {
|
pertinent: { type: "boolean" },
|
||||||
type: "string",
|
|
||||||
enum: ["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"],
|
|
||||||
},
|
|
||||||
typeVeille: {
|
typeVeille: {
|
||||||
type: "string",
|
anyOf: [
|
||||||
enum: ["reglementaire", "concurrentielle", "technologique", "generale"],
|
{
|
||||||
|
type: "string",
|
||||||
|
enum: ["reglementaire", "concurrentielle", "technologique", "informationnelle"],
|
||||||
|
},
|
||||||
|
{ type: "null" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
raison: { type: "string" },
|
raison: { type: "string" },
|
||||||
},
|
},
|
||||||
required: ["categorie", "typeVeille", "raison"],
|
required: ["pertinent", "typeVeille", "raison"],
|
||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -198,35 +148,37 @@ async function classifyCategory(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const rawContent = response?.choices?.[0]?.message?.content;
|
const rawContent = response?.choices?.[0]?.message?.content;
|
||||||
if (!rawContent) return null;
|
if (!rawContent) throw new Error("Réponse LLM vide");
|
||||||
const content = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
|
|
||||||
|
|
||||||
|
const content = typeof rawContent === "string" ? rawContent : JSON.stringify(rawContent);
|
||||||
const parsed = parseJsonSafe<{
|
const parsed = parseJsonSafe<{
|
||||||
categorie: AiCategorie;
|
pertinent: boolean;
|
||||||
typeVeille: AiTypeVeille;
|
typeVeille: AiTypeVeille | null;
|
||||||
raison: string;
|
raison: string;
|
||||||
}>(content);
|
}>(content);
|
||||||
return parsed;
|
|
||||||
|
if (!parsed) throw new Error("JSON LLM invalide");
|
||||||
|
|
||||||
|
return {
|
||||||
|
relevant: parsed.pertinent,
|
||||||
|
typeVeille: parsed.pertinent ? (parsed.typeVeille ?? "informationnelle") : null,
|
||||||
|
reason: parsed.raison,
|
||||||
|
classifiedBy: "ia",
|
||||||
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[AI Classifier] Erreur étape 2 (classification):", (e as Error).message);
|
console.error("[AI Classifier] Erreur classification:", (e as Error).message);
|
||||||
return null;
|
// Fallback sur les règles
|
||||||
|
const fb = fallbackFn();
|
||||||
|
return {
|
||||||
|
relevant: true,
|
||||||
|
typeVeille: fb.typeVeille,
|
||||||
|
reason: "Classification par règles (erreur LLM)",
|
||||||
|
classifiedBy: "rules",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Prompt 3 : Génération de résumé IA ──────────────────────────────────────────
|
// ─── Génération de résumé IA ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const PROMPT_RESUME = `Tu es un expert des établissements et services sociaux et médico-sociaux (ESMS).
|
|
||||||
Tu dois rédiger un résumé clair et professionnel de l'article fourni, destiné aux professionnels du secteur médico-social.
|
|
||||||
|
|
||||||
Consignes :
|
|
||||||
- Rédige 3 à 5 phrases en français, en langage clair et accessible
|
|
||||||
- Mets en avant les points clés : qui est concerné, quelle mesure ou information, quel impact pour les structures
|
|
||||||
- Reste factuel et objectif, sans jugement de valeur
|
|
||||||
- N'utilise pas de listes à puces, écris en prose
|
|
||||||
- Ne commence pas par "Cet article" ou "Le texte"
|
|
||||||
- Adapte le vocabulaire au secteur médico-social (ESMS, ARS, MDPH, etc.)
|
|
||||||
|
|
||||||
Réponds UNIQUEMENT avec le texte du résumé, sans introduction ni conclusion.`;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Génère un résumé IA de 3-5 phrases pour un article pertinent.
|
* Génère un résumé IA de 3-5 phrases pour un article pertinent.
|
||||||
@@ -249,74 +201,9 @@ export async function generateSummary(
|
|||||||
const rawContent = response?.choices?.[0]?.message?.content;
|
const rawContent = response?.choices?.[0]?.message?.content;
|
||||||
if (!rawContent) return null;
|
if (!rawContent) return null;
|
||||||
const text = typeof rawContent === "string" ? rawContent.trim() : null;
|
const text = typeof rawContent === "string" ? rawContent.trim() : null;
|
||||||
return text && text.length > 20 ? text : null;
|
return text && text.length > 10 ? text : null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[AI Classifier] Erreur génération résumé:", (e as Error).message);
|
console.error("[AI Classifier] Erreur génération résumé:", (e as Error).message);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Point d'entrée principal ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classifie un article en deux étapes via l'IA.
|
|
||||||
* Retourne toujours un résultat (fallback sur rules en cas d'erreur).
|
|
||||||
*
|
|
||||||
* @param titre Titre de l'article
|
|
||||||
* @param resume Résumé / description de l'article
|
|
||||||
* @param fallbackFn Fonction de fallback (classification par règles) appelée si l'IA échoue
|
|
||||||
*/
|
|
||||||
export async function classifyArticle(
|
|
||||||
titre: string,
|
|
||||||
resume: string,
|
|
||||||
fallbackFn: () => { categorie: AiCategorie; typeVeille: AiTypeVeille }
|
|
||||||
): Promise<AiClassificationResult> {
|
|
||||||
// ── Étape 1 : Pertinence ──────────────────────────────────────────────────
|
|
||||||
const relevanceResult = await checkRelevance(titre, resume);
|
|
||||||
|
|
||||||
if (!relevanceResult) {
|
|
||||||
// Erreur LLM → fallback
|
|
||||||
const fb = fallbackFn();
|
|
||||||
return {
|
|
||||||
relevant: true, // on suppose pertinent par défaut
|
|
||||||
categorie: fb.categorie,
|
|
||||||
typeVeille: fb.typeVeille,
|
|
||||||
reason: "Classification par règles (erreur LLM étape 1)",
|
|
||||||
classifiedBy: "rules",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!relevanceResult.pertinent) {
|
|
||||||
// Article non pertinent → on l'insère quand même avec categorie "Autre"
|
|
||||||
return {
|
|
||||||
relevant: false,
|
|
||||||
categorie: "Autre",
|
|
||||||
typeVeille: "generale",
|
|
||||||
reason: relevanceResult.raison,
|
|
||||||
classifiedBy: "ia",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Étape 2 : Classification ──────────────────────────────────────────────
|
|
||||||
const classResult = await classifyCategory(titre, resume);
|
|
||||||
|
|
||||||
if (!classResult) {
|
|
||||||
// Erreur LLM → fallback
|
|
||||||
const fb = fallbackFn();
|
|
||||||
return {
|
|
||||||
relevant: true,
|
|
||||||
categorie: fb.categorie,
|
|
||||||
typeVeille: fb.typeVeille,
|
|
||||||
reason: `Pertinent (${relevanceResult.raison}) — classification par règles (erreur LLM étape 2)`,
|
|
||||||
classifiedBy: "rules",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
relevant: true,
|
|
||||||
categorie: classResult.categorie,
|
|
||||||
typeVeille: classResult.typeVeille,
|
|
||||||
reason: classResult.raison,
|
|
||||||
classifiedBy: "ia",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export async function getVeilleItems(filters: VeilleFilters = {}) {
|
|||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
const conditions = [];
|
const conditions = [];
|
||||||
if (f.typeVeille) conditions.push(eq(veilleItems.typeVeille, f.typeVeille as "reglementaire" | "concurrentielle" | "technologique" | "generale"));
|
if (f.typeVeille) conditions.push(eq(veilleItems.typeVeille, f.typeVeille as "reglementaire" | "concurrentielle" | "technologique" | "informationnelle"));
|
||||||
if (f.categorie) conditions.push(like(veilleItems.categorie, `%${f.categorie}%`));
|
if (f.categorie) conditions.push(like(veilleItems.categorie, `%${f.categorie}%`));
|
||||||
if (f.niveau) conditions.push(like(veilleItems.niveau, `%${f.niveau}%`));
|
if (f.niveau) conditions.push(like(veilleItems.niveau, `%${f.niveau}%`));
|
||||||
if (f.territoire) conditions.push(like(veilleItems.territoire, `%${f.territoire}%`));
|
if (f.territoire) conditions.push(like(veilleItems.territoire, `%${f.territoire}%`));
|
||||||
|
|||||||
@@ -161,13 +161,14 @@ export async function getImportConfig(): Promise<ImportConfig> {
|
|||||||
|
|
||||||
// ─── Import Veille Stratégique ───────────────────────────────────────────────
|
// ─── Import Veille Stratégique ───────────────────────────────────────────────
|
||||||
|
|
||||||
const VEILLE_SHEETS: Record<string, "reglementaire" | "concurrentielle" | "technologique" | "generale"> = {
|
const VEILLE_SHEETS: Record<string, "reglementaire" | "concurrentielle" | "technologique" | "informationnelle"> = {
|
||||||
réglementaire: "reglementaire",
|
réglementaire: "reglementaire",
|
||||||
reglementaire: "reglementaire",
|
reglementaire: "reglementaire",
|
||||||
concurrentielle: "concurrentielle",
|
concurrentielle: "concurrentielle",
|
||||||
technologique: "technologique",
|
technologique: "technologique",
|
||||||
générale: "generale",
|
générale: "informationnelle",
|
||||||
generale: "generale",
|
generale: "informationnelle",
|
||||||
|
informationnelle: "informationnelle",
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function importVeille(config: ImportConfig): Promise<ImportResult> {
|
export async function importVeille(config: ImportConfig): Promise<ImportResult> {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const appRouter = router({
|
|||||||
list: publicProcedure
|
list: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
typeVeille: z.enum(["reglementaire", "concurrentielle", "technologique", "generale"]).optional(),
|
typeVeille: z.enum(["reglementaire", "concurrentielle", "technologique", "informationnelle"]).optional(),
|
||||||
categorie: z.string().optional(),
|
categorie: z.string().optional(),
|
||||||
niveau: z.string().optional(),
|
niveau: z.string().optional(),
|
||||||
territoire: z.string().optional(),
|
territoire: z.string().optional(),
|
||||||
@@ -131,15 +131,13 @@ export const appRouter = router({
|
|||||||
const aiResult = await classifyArticle(
|
const aiResult = await classifyArticle(
|
||||||
row.titre || "",
|
row.titre || "",
|
||||||
row.resume || "",
|
row.resume || "",
|
||||||
() => ({ categorie: (row.categorie as any) || "Autre", typeVeille: row.typeVeille || "generale" })
|
() => ({ typeVeille: (row.typeVeille || "informationnelle") as "reglementaire" | "concurrentielle" | "technologique" | "informationnelle" })
|
||||||
);
|
);
|
||||||
await db
|
await db
|
||||||
.update(veilleItems)
|
.update(veilleItems)
|
||||||
.set({
|
.set({
|
||||||
categorie: aiResult.categorie,
|
typeVeille: (aiResult.typeVeille ?? row.typeVeille ?? "informationnelle") as any,
|
||||||
typeVeille: (aiResult.typeVeille ?? row.typeVeille ?? "generale") as any,
|
|
||||||
iaRelevant: aiResult.relevant,
|
iaRelevant: aiResult.relevant,
|
||||||
iaCategorie: aiResult.categorie,
|
|
||||||
iaClassifiedBy: aiResult.classifiedBy,
|
iaClassifiedBy: aiResult.classifiedBy,
|
||||||
iaReason: aiResult.reason,
|
iaReason: aiResult.reason,
|
||||||
})
|
})
|
||||||
@@ -253,14 +251,12 @@ export const appRouter = router({
|
|||||||
const aiResult = await classifyArticle(
|
const aiResult = await classifyArticle(
|
||||||
row.titre || "",
|
row.titre || "",
|
||||||
"",
|
"",
|
||||||
() => ({ categorie: (row.categorie as any) || "Autre", typeVeille: "generale" as const })
|
() => ({ typeVeille: "informationnelle" as const })
|
||||||
);
|
);
|
||||||
await db
|
await db
|
||||||
.update(aapItems)
|
.update(aapItems)
|
||||||
.set({
|
.set({
|
||||||
categorie: aiResult.categorie as any,
|
|
||||||
iaRelevant: aiResult.relevant,
|
iaRelevant: aiResult.relevant,
|
||||||
iaCategorie: aiResult.categorie,
|
|
||||||
iaClassifiedBy: aiResult.classifiedBy,
|
iaClassifiedBy: aiResult.classifiedBy,
|
||||||
iaReason: aiResult.reason,
|
iaReason: aiResult.reason,
|
||||||
})
|
})
|
||||||
@@ -523,7 +519,7 @@ export const appRouter = router({
|
|||||||
url: z.string().url("URL invalide"),
|
url: z.string().url("URL invalide"),
|
||||||
name: z.string().min(1, "Nom requis").max(255),
|
name: z.string().min(1, "Nom requis").max(255),
|
||||||
feedType: z.enum(["veille", "aap"]),
|
feedType: z.enum(["veille", "aap"]),
|
||||||
defaultTypeVeille: z.enum(["reglementaire", "concurrentielle", "technologique", "generale"]).optional(),
|
defaultTypeVeille: z.enum(["reglementaire", "concurrentielle", "technologique", "informationnelle"]).optional(),
|
||||||
defaultCategorieAap: z.enum(["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]).optional(),
|
defaultCategorieAap: z.enum(["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]).optional(),
|
||||||
autoRules: z.array(z.object({
|
autoRules: z.array(z.object({
|
||||||
keyword: z.string(),
|
keyword: z.string(),
|
||||||
@@ -551,7 +547,7 @@ export const appRouter = router({
|
|||||||
url: z.string().url("URL invalide").optional(),
|
url: z.string().url("URL invalide").optional(),
|
||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
feedType: z.enum(["veille", "aap"]).optional(),
|
feedType: z.enum(["veille", "aap"]).optional(),
|
||||||
defaultTypeVeille: z.enum(["reglementaire", "concurrentielle", "technologique", "generale"]).nullable().optional(),
|
defaultTypeVeille: z.enum(["reglementaire", "concurrentielle", "technologique", "informationnelle"]).nullable().optional(),
|
||||||
defaultCategorieAap: z.enum(["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]).nullable().optional(),
|
defaultCategorieAap: z.enum(["Handicap", "PA", "Enfance", "Précarité", "Sanitaire", "Autre"]).nullable().optional(),
|
||||||
autoRules: z.array(z.object({
|
autoRules: z.array(z.object({
|
||||||
keyword: z.string(),
|
keyword: z.string(),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ interface RssItem {
|
|||||||
|
|
||||||
interface AutoRule {
|
interface AutoRule {
|
||||||
keyword: string;
|
keyword: string;
|
||||||
typeVeille?: "reglementaire" | "concurrentielle" | "technologique" | "generale";
|
typeVeille?: "reglementaire" | "concurrentielle" | "technologique" | "informationnelle";
|
||||||
categorieAap?: "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
|
categorieAap?: "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,16 +360,14 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
|||||||
() => {
|
() => {
|
||||||
const matchedRule = applyAutoRules(title, description, rules);
|
const matchedRule = applyAutoRules(title, description, rules);
|
||||||
return {
|
return {
|
||||||
categorie: detectVeilleCategorie(fullText),
|
typeVeille: (matchedRule?.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
|
||||||
typeVeille: (matchedRule?.typeVeille ?? feed.defaultTypeVeille ?? "generale") as
|
"reglementaire" | "concurrentielle" | "technologique" | "informationnelle",
|
||||||
"reglementaire" | "concurrentielle" | "technologique" | "generale",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const categorie = aiResult.categorie;
|
const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as
|
||||||
const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "generale") as
|
"reglementaire" | "concurrentielle" | "technologique" | "informationnelle";
|
||||||
"reglementaire" | "concurrentielle" | "technologique" | "generale";
|
|
||||||
|
|
||||||
// Générer le résumé IA uniquement pour les articles pertinents
|
// Générer le résumé IA uniquement pour les articles pertinents
|
||||||
const iaResume = aiResult.relevant
|
const iaResume = aiResult.relevant
|
||||||
@@ -381,7 +379,6 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
|||||||
await db.insert(veilleItems).values({
|
await db.insert(veilleItems).values({
|
||||||
dedupKey,
|
dedupKey,
|
||||||
titre: title,
|
titre: title,
|
||||||
categorie,
|
|
||||||
niveau,
|
niveau,
|
||||||
territoire,
|
territoire,
|
||||||
territoires: JSON.stringify(territoire !== "France" && territoire !== "Auvergne-Rhône-Alpes" ? [territoire] : []),
|
territoires: JSON.stringify(territoire !== "France" && territoire !== "Auvergne-Rhône-Alpes" ? [territoire] : []),
|
||||||
@@ -391,7 +388,6 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
|||||||
typeVeille,
|
typeVeille,
|
||||||
datePublication: pubDate,
|
datePublication: pubDate,
|
||||||
iaRelevant: aiResult.relevant,
|
iaRelevant: aiResult.relevant,
|
||||||
iaCategorie: aiResult.categorie,
|
|
||||||
iaClassifiedBy: aiResult.classifiedBy,
|
iaClassifiedBy: aiResult.classifiedBy,
|
||||||
iaReason: aiResult.reason,
|
iaReason: aiResult.reason,
|
||||||
iaResume: iaResume || null,
|
iaResume: iaResume || null,
|
||||||
@@ -427,17 +423,12 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
|||||||
const aiResult = await classifyArticle(
|
const aiResult = await classifyArticle(
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
() => {
|
() => ({
|
||||||
const matchedRule = applyAutoRules(title, description, rules);
|
typeVeille: "informationnelle" as const,
|
||||||
return {
|
})
|
||||||
categorie: (matchedRule?.categorieAap ?? feed.defaultCategorieAap ?? "Autre") as
|
|
||||||
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre",
|
|
||||||
typeVeille: "generale" as const,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const categorie = aiResult.categorie as
|
const categorie = (feed.defaultCategorieAap ?? "Autre") as
|
||||||
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
|
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
|
||||||
|
|
||||||
// Générer le résumé IA uniquement pour les articles pertinents
|
// Générer le résumé IA uniquement pour les articles pertinents
|
||||||
@@ -456,7 +447,6 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
|
|||||||
lien: link || null,
|
lien: link || null,
|
||||||
datePublication: pubDate,
|
datePublication: pubDate,
|
||||||
iaRelevant: aiResult.relevant,
|
iaRelevant: aiResult.relevant,
|
||||||
iaCategorie: aiResult.categorie,
|
|
||||||
iaClassifiedBy: aiResult.classifiedBy,
|
iaClassifiedBy: aiResult.classifiedBy,
|
||||||
iaReason: aiResult.reason,
|
iaReason: aiResult.reason,
|
||||||
iaResume: iaResume || null,
|
iaResume: iaResume || null,
|
||||||
|
|||||||
@@ -69,13 +69,14 @@ function normalizeStr(v: unknown): string | null {
|
|||||||
|
|
||||||
// ─── Mapping des feuilles ─────────────────────────────────────────────────────
|
// ─── Mapping des feuilles ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
const VEILLE_SHEETS: Record<string, "reglementaire" | "concurrentielle" | "technologique" | "generale"> = {
|
const VEILLE_SHEETS: Record<string, "reglementaire" | "concurrentielle" | "technologique" | "informationnelle"> = {
|
||||||
"réglementaire": "reglementaire",
|
"réglementaire": "reglementaire",
|
||||||
"reglementaire": "reglementaire",
|
"reglementaire": "reglementaire",
|
||||||
"concurrentielle": "concurrentielle",
|
"concurrentielle": "concurrentielle",
|
||||||
"technologique": "technologique",
|
"technologique": "technologique",
|
||||||
"générale": "generale",
|
"générale": "informationnelle",
|
||||||
"generale": "generale",
|
"generale": "informationnelle",
|
||||||
|
"informationnelle": "informationnelle",
|
||||||
};
|
};
|
||||||
|
|
||||||
const AAP_SHEETS: Record<string, "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre"> = {
|
const AAP_SHEETS: Record<string, "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre"> = {
|
||||||
|
|||||||
43
todo.md
43
todo.md
@@ -121,23 +121,32 @@
|
|||||||
|
|
||||||
## Résumé IA automatique + Marquage Lu/Non lu
|
## Résumé IA automatique + Marquage Lu/Non lu
|
||||||
|
|
||||||
- [ ] Schéma BDD : ajouter iaResume (text) dans veille_items et aap_items
|
- [x] Schéma BDD : ajouter iaResume (text) dans veille_items et aap_items
|
||||||
- [ ] Schéma BDD : créer table article_reads (id, userId, articleType, articleId, readAt)
|
- [x] Schéma BDD : créer table article_reads (id, userId, articleType, articleId, readAt)
|
||||||
- [ ] Migration pnpm db:push
|
- [x] Migration pnpm db:push
|
||||||
- [ ] aiClassifier.ts : ajouter generateSummary() — résumé 3-5 lignes en français adapté au secteur médico-social
|
- [x] aiClassifier.ts : ajouter generateSummary() — résumé 3-5 lignes en français adapté au secteur médico-social
|
||||||
- [ ] rssEngine.ts : appeler generateSummary() après classifyArticle() pour chaque article pertinent
|
- [x] rssEngine.ts : appeler generateSummary() après classifyArticle() pour chaque article pertinent
|
||||||
- [ ] Procédures tRPC : veille.markAsRead, veille.unreadCount
|
- [x] Procédures tRPC : veille.markAsRead, veille.unreadCount
|
||||||
- [ ] Procédures tRPC : aap.markAsRead, aap.unreadCount
|
- [x] Procédures tRPC : aap.markAsRead, aap.unreadCount
|
||||||
- [ ] VeilleDashboard : afficher iaResume dans la boîte de dialogue détail (à la place du résumé brut si disponible)
|
- [x] VeilleDashboard : afficher iaResume dans la boîte de dialogue détail (à la place du résumé brut si disponible)
|
||||||
- [ ] VeilleDashboard : point bleu sur les articles non lus, clic sur article = markAsRead automatique
|
- [x] VeilleDashboard : point bleu sur les articles non lus, clic sur article = markAsRead automatique
|
||||||
- [ ] AAPDashboard : même logique lu/non lu
|
- [x] AAPDashboard : même logique lu/non lu
|
||||||
- [ ] Sidebar DashboardLayout : compteur non lus sur les entrées Veille et AAP
|
- [x] Sidebar DashboardLayout : compteur non lus sur les entrées Veille et AAP
|
||||||
- [ ] Tests visuels en sandbox
|
- [x] Tests visuels en sandbox
|
||||||
- [ ] Checkpoint
|
- [x] Checkpoint
|
||||||
|
|
||||||
## Corrections urgentes — Classification IA + Déploiement recette
|
## Corrections urgentes — Classification IA + Déploiement recette
|
||||||
|
|
||||||
- [ ] Corriger le prompt de pertinence dans aiClassifier.ts (articles hors périmètre passent le filtre)
|
- [x] Corriger le prompt de pertinence dans aiClassifier.ts (articles hors périmètre passent le filtre)
|
||||||
- [ ] Tester en sandbox que les articles hors périmètre (pêche, sport, économie générale) sont bien rejetés
|
- [x] Tester en sandbox que les articles hors périmètre (pêche, sport, économie générale) sont bien rejetés
|
||||||
- [ ] Finaliser le déploiement recette : copier les fichiers frontend (marqueur lu/non lu) dans le conteneur
|
- [x] Finaliser le déploiement recette : copier les fichiers frontend (marqueur lu/non lu) dans le conteneur
|
||||||
- [ ] Vérifier visuellement en recette que le marqueur lu/non lu et les compteurs s'affichent
|
- [x] Vérifier visuellement en recette que le marqueur lu/non lu et les compteurs s'affichent
|
||||||
|
|
||||||
|
## Simplification : suppression catégorie médico-sociale dans Veille Stratégique
|
||||||
|
- [x] aiClassifier.ts : fusionner les deux prompts en un seul (pertinence + type de veille en une étape)
|
||||||
|
- [x] aiClassifier.ts : le résultat retourne uniquement typeVeille (reglementaire/concurrentielle/technologique/informationnelle) ou null si hors périmètre
|
||||||
|
- [x] rssEngine.ts : mettre à jour l'appel à classifyArticle pour utiliser le nouveau retour simplifié
|
||||||
|
- [x] VeilleDashboard.tsx : supprimer la colonne Catégorie de la vue liste
|
||||||
|
- [x] VeilleDashboard.tsx : supprimer tout filtre ou affichage lié à iaCategorie
|
||||||
|
- [x] Tester visuellement en sandbox
|
||||||
|
- [x] Checkpoint
|
||||||
|
|||||||
Reference in New Issue
Block a user