/** * Agent Suggestion Service * * Proposes research/monitor agents from existing semantic clusters (no duplicate DBSCAN). * Runs after clustering; compares cluster themes vs existing agents. */ import prisma from '@/lib/prisma' import { clusteringService } from './clustering.service' import { getChatProvider } from '@/lib/ai/factory' import { getSystemConfig } from '@/lib/config' import { calculateNextRun } from '@/lib/agents/schedule' const MIN_CLUSTER_NOTES = 3 const MAX_SUGGESTIONS_PER_RUN = 3 export interface AgentSuggestionPayload { topic: string reason: string suggestedRole: string suggestedType: string suggestedFrequency: string relatedNoteIds: string[] clusterId: number } function parseJsonArray(raw: string | null | undefined): string[] { if (!raw) return [] try { const parsed = JSON.parse(raw) return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [] } catch { return [] } } function clusterCoveredByAgent( topic: string, noteIds: string[], agent: { name: string; role: string; description: string | null; sourceNoteIds: string | null }, ): boolean { const topicLower = topic.toLowerCase() const haystack = `${agent.name} ${agent.role} ${agent.description || ''}`.toLowerCase() const topicWords = topicLower.split(/\s+/).filter(w => w.length > 3) if (topicWords.some(w => haystack.includes(w))) return true const agentNoteIds = parseJsonArray(agent.sourceNoteIds) if (agentNoteIds.length === 0 || noteIds.length === 0) return false const overlap = agentNoteIds.filter(id => noteIds.includes(id)).length return overlap / noteIds.length >= 0.4 } async function buildSuggestionWithLlm( topic: string, noteTitles: string[], noteCount: number, ): Promise> { const fallback = { reason: `${noteCount} notes récentes sur ce thème — aucun agent ne le couvre encore.`, suggestedRole: `Recherche et synthétise les dernières informations sur « ${topic} ». Croise avec les notes existantes de l'utilisateur et produis un résumé actionnable.`, suggestedType: 'researcher', suggestedFrequency: 'weekly', } try { const config = await getSystemConfig() const provider = getChatProvider(config) if (!provider) return fallback const prompt = `Tu proposes un agent IA pour un second cerveau (prise de notes). Thème détecté: "${topic}" Notes liées (titres): ${noteTitles.slice(0, 5).join(' | ') || 'sans titre'} Nombre de notes: ${noteCount} Retourne UNIQUEMENT du JSON valide: {"reason":"1 phrase pourquoi un agent est utile","suggestedRole":"prompt système de l'agent (2-3 phrases, français)","suggestedType":"researcher|monitor","suggestedFrequency":"daily|weekly"}` const raw = await provider.generateText(prompt) const match = raw.match(/\{[\s\S]*\}/) if (!match) return fallback const parsed = JSON.parse(match[0]) return { reason: String(parsed.reason || fallback.reason).slice(0, 500), suggestedRole: String(parsed.suggestedRole || fallback.suggestedRole).slice(0, 2000), suggestedType: parsed.suggestedType === 'monitor' ? 'monitor' : 'researcher', suggestedFrequency: ['daily', 'weekly'].includes(parsed.suggestedFrequency) ? parsed.suggestedFrequency : 'weekly', } } catch { return fallback } } export class AgentSuggestionService { async generateForUser(userId: string): Promise { const stored = await clusteringService.getStoredClusters(userId) if (!stored || stored.clusters.length === 0) return 0 const agents = await prisma.agent.findMany({ where: { userId, isEnabled: true }, select: { name: true, role: true, description: true, sourceNoteIds: true }, }) const candidates = stored.clusters .filter(c => c.noteIds.length >= MIN_CLUSTER_NOTES) .sort((a, b) => b.noteIds.length - a.noteIds.length) let created = 0 for (const cluster of candidates) { if (created >= MAX_SUGGESTIONS_PER_RUN) break const topic = cluster.name || `Thème ${cluster.clusterId + 1}` const covered = agents.some(a => clusterCoveredByAgent(topic, cluster.noteIds, a)) if (covered) continue const existing = await prisma.agentSuggestion.findUnique({ where: { userId_clusterId: { userId, clusterId: cluster.clusterId } }, }) if (existing && existing.status !== 'pending') continue const notes = await prisma.note.findMany({ where: { id: { in: cluster.noteIds.slice(0, 8) }, userId }, select: { title: true }, }) const llm = await buildSuggestionWithLlm( topic, notes.map(n => n.title || 'Sans titre'), cluster.noteIds.length, ) const payload = { topic, reason: llm.reason, suggestedRole: llm.suggestedRole, suggestedType: llm.suggestedType, suggestedFrequency: llm.suggestedFrequency, relatedNoteIds: JSON.stringify(cluster.noteIds.slice(0, 20)), status: 'pending' as const, } if (existing) { await prisma.agentSuggestion.update({ where: { id: existing.id }, data: payload, }) } else { await prisma.agentSuggestion.create({ data: { userId, clusterId: cluster.clusterId, ...payload }, }) } created++ } return created } async getPending(userId: string, limit = 3) { return prisma.agentSuggestion.findMany({ where: { userId, status: 'pending' }, orderBy: { updatedAt: 'desc' }, take: limit, }) } async dismiss(userId: string, id: string) { const row = await prisma.agentSuggestion.findFirst({ where: { id, userId } }) if (!row) return null return prisma.agentSuggestion.update({ where: { id }, data: { status: 'dismissed' }, }) } async accept(userId: string, id: string): Promise<{ agentId: string } | null> { const row = await prisma.agentSuggestion.findFirst({ where: { id, userId, status: 'pending' } }) if (!row) return null const noteIds = parseJsonArray(row.relatedNoteIds) const agent = await prisma.agent.create({ data: { userId, name: `Recherche — ${row.topic}`.slice(0, 80), description: row.reason, type: row.suggestedType, role: row.suggestedRole, frequency: row.suggestedFrequency, sourceNoteIds: noteIds.length > 0 ? JSON.stringify(noteIds) : null, tools: JSON.stringify(['web_search', 'read_url']), isEnabled: true, scheduledTime: '08:00', }, }) if (row.suggestedFrequency !== 'manual') { const nextRun = calculateNextRun({ frequency: row.suggestedFrequency, scheduledTime: '08:00', }) if (nextRun) { await prisma.agent.update({ where: { id: agent.id }, data: { nextRun } }) } } await prisma.agentSuggestion.update({ where: { id: row.id }, data: { status: 'accepted' }, }) import('@/lib/ai/services/agent-executor.service') .then(({ executeAgent }) => executeAgent(agent.id, userId)) .catch(err => console.error('[AgentSuggestion] execute failed:', err)) return { agentId: agent.id } } } export const agentSuggestionService = new AgentSuggestionService()