import 'server-only' import prisma from '@/lib/prisma' import type { DashboardPath } from '@/lib/dashboard/path-types' export type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types' interface BuildPathsInput { userId: string aiActive: boolean recentNotes: Array<{ id: string title: string | null notebookId: string | null updatedAt: Date content: string }> inboxCount: number dueFlashcards: number insights: Array<{ id: string insight: string score: number viewed: boolean note1: { id: string; title: string | null } note2: { id: string; title: string | null } note1Excerpt?: string note2Excerpt?: string }> bridgeSuggestions: Array<{ clusterAId: number clusterBId: number clusterAName: string clusterBName: string suggestedTitle: string suggestedContent: string justification: string }> agentSuggestions: Array<{ id: string topic: string reason: string clusterId: number | null }> } function excerpt(text: string, max = 120): string { const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim() if (plain.length <= max) return plain return `${plain.slice(0, max)}…` } function jaccardSimilarity(a: string, b: string): number { const tokenize = (s: string) => new Set(s.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 3)) const A = tokenize(a) const B = tokenize(b) if (A.size === 0 || B.size === 0) return 0 let intersection = 0 A.forEach(w => { if (B.has(w)) intersection++ }) return intersection / (A.size + B.size - intersection) } async function findLinkSuggestions( userId: string, sourceNote: { id: string; title: string | null; content: string }, limit = 2, ): Promise> { const sourcePlain = excerpt(sourceNote.content, 400) const sourceText = `${sourceNote.title || ''} ${sourcePlain}`.toLowerCase() const candidates = await prisma.note.findMany({ where: { userId, id: { not: sourceNote.id }, isArchived: false, trashedAt: null }, select: { id: true, title: true, content: true }, take: 12, orderBy: { updatedAt: 'desc' }, }) const scored: Array<{ noteId: string; noteTitle: string; snippet: string; score: number }> = [] for (const note of candidates) { const notePlain = excerpt(note.content, 300) const sim = jaccardSimilarity(sourceText, `${note.title || ''} ${notePlain}`) if (sim < 0.06) continue scored.push({ noteId: note.id, noteTitle: note.title || '', snippet: notePlain.slice(0, 100), score: Math.round(sim * 100), }) } return scored.sort((a, b) => b.score - a.score).slice(0, limit) } function pathsFromInsights( focus: BuildPathsInput['recentNotes'][0], insights: BuildPathsInput['insights'], paths: DashboardPath[], ): void { for (const ins of insights .filter(i => i.note1.id === focus.id || i.note2.id === focus.id) .slice(0, 2)) { const other = ins.note1.id === focus.id ? ins.note2 : ins.note1 if (paths.some(p => p.note2Id === other.id && p.type === 'connect')) continue paths.push({ id: `connect-${focus.id}-${other.id}`, type: 'connect', priority: 90 - paths.length, title: other.title || 'Untitled', description: ins.insight || ins.note2Excerpt || ins.note1Excerpt || '', actionKey: 'compare', noteId: focus.id, note2Id: other.id, notebookId: focus.notebookId ?? undefined, score: Math.round(ins.score * 100), }) } } async function findOpenLoops(userId: string, limit = 3): Promise> { const threeDaysAgo = new Date() threeDaysAgo.setDate(threeDaysAgo.getDate() - 3) const thirtyDaysAgo = new Date() thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30) const stale = await prisma.note.findMany({ where: { userId, trashedAt: null, isArchived: false, updatedAt: { lt: threeDaysAgo, gte: thirtyDaysAgo }, OR: [ { notebookId: null }, { isPinned: true }, { reminder: { not: null }, isReminderDone: false }, ], }, select: { id: true, title: true, notebookId: true, updatedAt: true }, orderBy: { updatedAt: 'desc' }, take: limit, }) const now = Date.now() return stale.map(n => ({ id: n.id, title: n.title, notebookId: n.notebookId, daysStale: Math.floor((now - n.updatedAt.getTime()) / (1000 * 60 * 60 * 24)), })) } export async function buildDashboardPaths(input: BuildPathsInput): Promise { const paths: DashboardPath[] = [] const focus = input.recentNotes[0] let focusClusterId: number | null = null if (focus) { const member = await prisma.clusterMember.findFirst({ where: { userId: input.userId, noteId: focus.id }, select: { clusterId: true }, }) focusClusterId = member?.clusterId ?? null paths.push({ id: `continue-${focus.id}`, type: 'continue', priority: 100, title: focus.title || 'Untitled', description: excerpt(focus.content, 140), actionKey: 'continue', noteId: focus.id, notebookId: focus.notebookId ?? undefined, }) pathsFromInsights(focus, input.insights, paths) const linkSuggestions = await findLinkSuggestions(input.userId, focus, 2) for (const link of linkSuggestions) { paths.push({ id: `add-link-${focus.id}-${link.noteId}`, type: 'add-link', priority: 75, title: link.noteTitle || 'Untitled', description: link.snippet, actionKey: 'addLink', noteId: focus.id, note2Id: link.noteId, notebookId: focus.notebookId ?? undefined, score: link.score, }) } } const freshInsight = input.insights.find(i => !i.viewed) if (freshInsight) { paths.push({ id: `resurface-${freshInsight.id}`, type: 'resurface', priority: 85, title: `${freshInsight.note1.title || '…'} ↔ ${freshInsight.note2.title || '…'}`, description: freshInsight.insight, actionKey: 'openInsight', insightId: freshInsight.id, noteId: freshInsight.note1.id, note2Id: freshInsight.note2.id, score: Math.round(freshInsight.score * 100), }) } for (const bridge of input.bridgeSuggestions.slice(0, 2)) { const relevant = focusClusterId === null || bridge.clusterAId === focusClusterId || bridge.clusterBId === focusClusterId paths.push({ id: `bridge-${bridge.clusterAId}-${bridge.clusterBId}`, type: 'bridge', priority: relevant ? 80 : 55, title: bridge.suggestedTitle, description: bridge.justification || bridge.suggestedContent.slice(0, 140), actionKey: 'createBridge', clusterAId: bridge.clusterAId, clusterBId: bridge.clusterBId, notebookId: focus?.notebookId ?? undefined, }) } for (const agent of input.agentSuggestions.slice(0, 2)) { const relevant = focusClusterId !== null && agent.clusterId === focusClusterId paths.push({ id: `research-${agent.id}`, type: 'research', priority: relevant ? 78 : 50, title: agent.topic, description: agent.reason, actionKey: 'createAgent', agentSuggestionId: agent.id, notebookId: focus?.notebookId ?? undefined, }) } if (focusClusterId !== null) { const cluster = await prisma.noteCluster.findFirst({ where: { userId: input.userId, clusterId: focusClusterId }, select: { name: true, noteCount: true }, }) if (cluster) { paths.push({ id: `explore-cluster-${focusClusterId}`, type: 'explore', priority: 60, title: cluster.name || `Cluster ${focusClusterId}`, description: `${cluster.noteCount} notes in this theme`, actionKey: 'exploreTheme', clusterAId: focusClusterId, }) } } if (input.inboxCount > 0) { paths.push({ id: 'organize-inbox', type: 'organize', priority: 70, title: `${input.inboxCount} notes`, description: 'inbox', actionKey: 'organizeInbox', }) } if (input.dueFlashcards > 0) { paths.push({ id: 'review-flashcards', type: 'review', priority: 65, title: `${input.dueFlashcards}`, description: 'flashcards', actionKey: 'reviewCards', }) } paths.push({ id: 'daily-journal', type: 'daily', priority: 40, title: new Date().toISOString().slice(0, 10), description: 'daily', actionKey: 'openDaily', }) return paths .sort((a, b) => b.priority - a.priority) .slice(0, 8) } export async function buildOpenLoops(userId: string) { return findOpenLoops(userId, 5) }