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>
This commit is contained in:
216
memento-note/lib/ai/services/agent-suggestion.service.ts
Normal file
216
memento-note/lib/ai/services/agent-suggestion.service.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 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()
|
||||
Reference in New Issue
Block a user