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>
217 lines
7.2 KiB
TypeScript
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()
|