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()
|
||||
@@ -10,6 +10,12 @@ import {
|
||||
prepareNoteTextForEmbedding,
|
||||
} from '@/lib/text/plain-text'
|
||||
import { detectTextDirection } from '@/lib/clip/rtl-content'
|
||||
import { detectUserLanguage } from '@/lib/i18n/detect-user-language'
|
||||
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
|
||||
import {
|
||||
buildMemoryEchoInsightPrompt,
|
||||
getMemoryEchoInsightFallback,
|
||||
} from '@/lib/ai/memory-echo-i18n'
|
||||
import {
|
||||
SEMANTIC_SIMILARITY_FLOOR_CLIP,
|
||||
SEMANTIC_SIMILARITY_FLOOR_DEMO,
|
||||
@@ -334,38 +340,16 @@ export class MemoryEchoService {
|
||||
note1Title: string | null,
|
||||
note1Content: string,
|
||||
note2Title: string | null,
|
||||
note2Content: string
|
||||
note2Content: string,
|
||||
language: SupportedLanguage = 'en',
|
||||
): Promise<string> {
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getChatProvider(config)
|
||||
|
||||
const note1Desc = note1Title || 'Untitled note'
|
||||
const note2Desc = note2Title || 'Untitled note'
|
||||
const excerpt1 = excerptPlainNoteContent(note1Title, note1Content, 1200)
|
||||
const excerpt2 = excerptPlainNoteContent(note2Title, note2Content, 1200)
|
||||
const directionSample = `${note1Desc}\n${excerpt1}\n${note2Desc}\n${excerpt2}`
|
||||
const isRtl = detectTextDirection(directionSample) === 'rtl'
|
||||
|
||||
const prompt = isRtl
|
||||
? `تو یک دستیار هستی که ارتباط بین یادداشتها را تحلیل میکنی.
|
||||
|
||||
یادداشت ۱: «${note1Desc}»
|
||||
متن: ${excerpt1}
|
||||
|
||||
یادداشت ۲: «${note2Desc}»
|
||||
متن: ${excerpt2}
|
||||
|
||||
در یک جمله کوتاه (حداکثر ۱۵ کلمه) به فارسی توضیح بده چرا این دو یادداشت به هم مرتبطاند. فقط رابطه معنایی را بگو.`
|
||||
: `You are a helpful assistant analyzing connections between notes.
|
||||
|
||||
Note 1: "${note1Desc}"
|
||||
Content: ${excerpt1}
|
||||
|
||||
Note 2: "${note2Desc}"
|
||||
Content: ${excerpt2}
|
||||
|
||||
Explain in one brief sentence (max 15 words) why these notes are connected. Focus on the semantic relationship.`
|
||||
const prompt = buildMemoryEchoInsightPrompt(language, note1Title, excerpt1, note2Title, excerpt2)
|
||||
|
||||
const response = await provider.generateText(prompt)
|
||||
|
||||
@@ -375,20 +359,15 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
||||
.trim()
|
||||
.substring(0, 150)
|
||||
|
||||
const fallback = isRtl
|
||||
? 'این یادداشتها از نظر معنایی به هم مرتبط به نظر میرسند.'
|
||||
: 'These notes appear to be semantically related.'
|
||||
|
||||
return insight || fallback
|
||||
return insight || getMemoryEchoInsightFallback(language)
|
||||
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to generate insight:', error)
|
||||
const sample = excerptPlainNoteContent(note1Title, note1Content, 200)
|
||||
+ excerptPlainNoteContent(note2Title, note2Content, 200)
|
||||
if (detectTextDirection(sample) === 'rtl') {
|
||||
return 'این یادداشتها از نظر معنایی به هم مرتبط به نظر میرسند.'
|
||||
}
|
||||
return 'These notes appear to be semantically related.'
|
||||
const rtl = detectTextDirection(sample) === 'rtl'
|
||||
if (rtl) return getMemoryEchoInsightFallback(language === 'fa' || language === 'ar' ? language : 'fa')
|
||||
return getMemoryEchoInsightFallback(language)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,12 +463,14 @@ Explain in one brief sentence (max 15 words) why these notes are connected. Focu
|
||||
return null // All connections already shown
|
||||
}
|
||||
|
||||
// Generate AI insight
|
||||
// Generate AI insight in user's language
|
||||
const userLanguage = await detectUserLanguage()
|
||||
const insightText = await this.generateInsight(
|
||||
newConnection.note1.title,
|
||||
newConnection.note1.content,
|
||||
newConnection.note2.title,
|
||||
newConnection.note2.content || ''
|
||||
newConnection.note2.content || '',
|
||||
userLanguage,
|
||||
)
|
||||
|
||||
// Store insight in database
|
||||
|
||||
Reference in New Issue
Block a user