Checkpoint: Implémentation du résumé automatique généré par l'IA pour chaque article RSS (veille et AAP), marquage lu/non lu avec point bleu et fond teinté, bouton "Tout marquer comme lu", compteurs non lus dans la sidebar et dans les titres de page. Migration BDD : table article_reads + colonne iaResume dans veille_items et aap_items.

This commit is contained in:
Manus
2026-06-16 03:08:29 -04:00
parent c47c76215e
commit 67dfbb1a13
12 changed files with 1324 additions and 35 deletions

View File

@@ -202,6 +202,49 @@ async function classifyCategory(
}
}
// ─── Prompt 3 : 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.
* Retourne null en cas d'échec (le résumé brut RSS sera utilisé à la place).
*/
export async function generateSummary(
titre: string,
resume: string
): Promise<string | null> {
const userContent = `Titre : ${titre}\n\nContenu : ${resume}`;
try {
const response = await invokeLLM({
messages: [
{ role: "system", content: PROMPT_RESUME },
{ role: "user", content: userContent },
],
});
const rawContent = response?.choices?.[0]?.message?.content;
if (!rawContent) return null;
const text = typeof rawContent === "string" ? rawContent.trim() : null;
return text && text.length > 20 ? text : null;
} catch (e) {
console.error("[AI Classifier] Erreur génération résumé:", (e as Error).message);
return null;
}
}
// ─── Point d'entrée principal ─────────────────────────────────────────────────
/**

View File

@@ -37,8 +37,8 @@ import { scheduleRssFetch } from "./_core/index";
import { loginLocalUser, hashPassword, ensureAdminExists } from "./localAuth";
import { classifyArticle } from "./aiClassifier";
import { getDb } from "./db";
import { veilleItems, aapItems } from "../drizzle/schema";
import { isNull, or, eq as eqDrizzle } from "drizzle-orm";
import { veilleItems, aapItems, articleReads } from "../drizzle/schema";
import { isNull, or, eq as eqDrizzle, and, inArray, count } from "drizzle-orm";
// ─── Middleware admin ─────────────────────────────────────────────────────────
@@ -152,6 +152,57 @@ export const appRouter = router({
return { processed, errors, total: rows.length };
}),
// ─── Marquage lu/non lu ──────────────────────────────────────────────────────────────────────
markAsRead: protectedProcedure
.input(z.object({ articleId: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
// Insérer seulement si pas déjà lu (ignore le doublon)
try {
await db.insert(articleReads).values({
userId: ctx.user.id,
articleType: "veille",
articleId: input.articleId,
});
} catch { /* doublon = déjà lu, on ignore */ }
return { success: true };
}),
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
// Récupérer tous les IDs veille
const allItems = await db.select({ id: veilleItems.id }).from(veilleItems);
const allIds = allItems.map((r: { id: number }) => r.id);
// Trouver ceux déjà lus
const alreadyRead = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "veille")));
const alreadyReadIds = new Set(alreadyRead.map((r: { articleId: number }) => r.articleId));
const toInsert = allIds.filter((id: number) => !alreadyReadIds.has(id)).map((id: number) => ({
userId: ctx.user.id, articleType: "veille" as const, articleId: id,
}));
if (toInsert.length > 0) {
await db.insert(articleReads).values(toInsert);
}
return { success: true, marked: toInsert.length };
}),
unreadCount: protectedProcedure.query(async ({ ctx }) => {
const db = await getDb();
if (!db) return { count: 0 };
const totalRows = await db.select({ cnt: count() }).from(veilleItems);
const total = totalRows[0]?.cnt ?? 0;
const readRows = await db
.select({ cnt: count() })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "veille")));
const read = readRows[0]?.cnt ?? 0;
return { count: Math.max(0, total - read) };
}),
}),
// ─── AAPP ────────────────────────────────────────────────────────────────────
aap: router({
@@ -222,6 +273,54 @@ export const appRouter = router({
return { processed, errors, total: rows.length };
}),
// ─── Marquage lu/non lu AAP ──────────────────────────────────────────────────────────────────────
markAsRead: protectedProcedure
.input(z.object({ articleId: z.number().int().positive() }))
.mutation(async ({ input, ctx }) => {
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
try {
await db.insert(articleReads).values({
userId: ctx.user.id,
articleType: "aap",
articleId: input.articleId,
});
} catch { /* doublon = déjà lu, on ignore */ }
return { success: true };
}),
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
const db = await getDb();
if (!db) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "DB indisponible" });
const allItems = await db.select({ id: aapItems.id }).from(aapItems);
const allIds = allItems.map((r: { id: number }) => r.id);
const alreadyRead = await db
.select({ articleId: articleReads.articleId })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "aap")));
const alreadyReadIds = new Set(alreadyRead.map((r: { articleId: number }) => r.articleId));
const toInsert = allIds.filter((id: number) => !alreadyReadIds.has(id)).map((id: number) => ({
userId: ctx.user.id, articleType: "aap" as const, articleId: id,
}));
if (toInsert.length > 0) {
await db.insert(articleReads).values(toInsert);
}
return { success: true, marked: toInsert.length };
}),
unreadCount: protectedProcedure.query(async ({ ctx }) => {
const db = await getDb();
if (!db) return { count: 0 };
const totalRows = await db.select({ cnt: count() }).from(aapItems);
const total = totalRows[0]?.cnt ?? 0;
const readRows = await db
.select({ cnt: count() })
.from(articleReads)
.where(and(eqDrizzle(articleReads.userId, ctx.user.id), eqDrizzle(articleReads.articleType, "aap")));
const read = readRows[0]?.cnt ?? 0;
return { count: Math.max(0, total - read) };
}),
}),
// ─── Importt ─────────────────────────────────────────────────────────────────
import: router({

View File

@@ -22,7 +22,7 @@ import {
type RssFeed,
} from "../drizzle/schema";
import { eq, sql } from "drizzle-orm";
import { classifyArticle } from "./aiClassifier";
import { classifyArticle, generateSummary } from "./aiClassifier";
// ─── Types internes ───────────────────────────────────────────────────────────
@@ -371,6 +371,11 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "generale") as
"reglementaire" | "concurrentielle" | "technologique" | "generale";
// Générer le résumé IA uniquement pour les articles pertinents
const iaResume = aiResult.relevant
? await generateSummary(title, description)
: null;
try {
// Essayer d'insérer
await db.insert(veilleItems).values({
@@ -389,6 +394,7 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
iaCategorie: aiResult.categorie,
iaClassifiedBy: aiResult.classifiedBy,
iaReason: aiResult.reason,
iaResume: iaResume || null,
});
result.newItems++;
} catch (e: any) {
@@ -434,6 +440,11 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
const categorie = aiResult.categorie as
"Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre";
// Générer le résumé IA uniquement pour les articles pertinents
const iaResume = aiResult.relevant
? await generateSummary(title, description)
: null;
try {
await db.insert(aapItems).values({
dedupKey,
@@ -448,6 +459,7 @@ async function processFeed(feed: RssFeed): Promise<FetchResult> {
iaCategorie: aiResult.categorie,
iaClassifiedBy: aiResult.classifiedBy,
iaReason: aiResult.reason,
iaResume: iaResume || null,
});
result.newItems++;
} catch (e: any) {