Files
Momento/memento-note/lib/ai/services/agent-suggestion.service.ts
Antigravity 30da592ba2
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m3s
CI / Deploy production (on server) (push) Successful in 23s
feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
Briefing granulaire, pistes rapides puis enrichissement async, layout persisté v5,
suggestions agents, intégration Gmail et navigation sidebar alignée sur /home.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:50:53 +00:00

217 lines
7.2 KiB
TypeScript

/**
* 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<Pick<AgentSuggestionPayload, 'reason' | 'suggestedRole' | 'suggestedType' | 'suggestedFrequency'>> {
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<number> {
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()