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:
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user