diff --git a/memento-note/app/api/ai/study-plan/route.ts b/memento-note/app/api/ai/study-plan/route.ts index 296d2f6..868bf9a 100644 --- a/memento-note/app/api/ai/study-plan/route.ts +++ b/memento-note/app/api/ai/study-plan/route.ts @@ -33,7 +33,7 @@ export async function POST(request: NextRequest) { where: { notebookId, trashedAt: null, userId: session.user.id }, select: { id: true, title: true }, orderBy: { order: 'asc' }, - }) + }) as Array<{ id: string; title: string | null }> if (notes.length === 0) { return NextResponse.json({ error: 'No notes found in notebook' }, { status: 400 }) @@ -44,7 +44,8 @@ export async function POST(request: NextRequest) { select: { theme: true }, }) - const plan = await studyPlannerService.generate(notes, examDate) + const notesForService = notes.map(n => ({ id: n.id, title: n.title ?? '' })) + const plan = await studyPlannerService.generate(notesForService, examDate) // Set reminders on notes based on the plan for (const day of plan.days) { diff --git a/memento-note/app/api/notebooks/csv/route.ts b/memento-note/app/api/notebooks/csv/route.ts index aa90eb6..852f0b5 100644 --- a/memento-note/app/api/notebooks/csv/route.ts +++ b/memento-note/app/api/notebooks/csv/route.ts @@ -121,7 +121,7 @@ export async function POST(request: NextRequest) { const headers = parseCSVLine(lines[0].replace(/^\uFEFF/, '')) const titleIdx = headers.findIndex(h => h.toLowerCase().includes('titre') || h.toLowerCase() === 'title') - const dataRows = lines.slice(1).filter(l => l.trim()) + const dataRows = lines.slice(1).filter((l: string) => l.trim()) // Get or create schema let schema = await prisma.notebookSchema.findUnique({ where: { notebookId } }) diff --git a/memento-note/components/organize-notebook-dialog.tsx b/memento-note/components/organize-notebook-dialog.tsx index 6856c00..9530f50 100644 --- a/memento-note/components/organize-notebook-dialog.tsx +++ b/memento-note/components/organize-notebook-dialog.tsx @@ -315,6 +315,7 @@ export function OrganizeNotebookDialog({ )} + )} diff --git a/memento-note/components/rich-text-editor.tsx b/memento-note/components/rich-text-editor.tsx index e66fa8b..78167bb 100644 --- a/memento-note/components/rich-text-editor.tsx +++ b/memento-note/components/rich-text-editor.tsx @@ -33,7 +33,6 @@ import { FindReplaceBar, FindReplaceExtension } from './editor-find-replace-bar' import { LinkPreviewExtension, insertLinkPreview, isUrl } from './tiptap-link-preview-extension' import { MathEquationExtension, InlineMathExtension, insertMathEquation } from './tiptap-math-extension' import { ColumnsExtension, ColumnNode, insertColumnsBlock } from './tiptap-columns-extension' -import { SubPageExtension, insertSubPageBlock } from './tiptap-subpage-extension' import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension' import { ClipArticleExtension } from './tiptap-clip-article-extension' import { BlockPicker, type BlockSuggestion } from './block-picker' @@ -70,7 +69,7 @@ import { FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight, Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus, SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database, - ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, FileOutput + ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2 } from 'lucide-react' import { cn } from '@/lib/utils' import { toast } from 'sonner' @@ -242,10 +241,6 @@ const slashCommands: SlashItem[] = [ { title: 'Écrire avec l\'IA', description: 'Générer du contenu au curseur', icon: Sparkles, category: 'IA Note', isAi: true, aiOption: 'write', command: () => { } }, - { - title: 'Sub-page', description: 'Create a linked sub-page', icon: FileOutput, category: 'Basic blocks', shortcut: '/page', - command: () => { } - }, ] async function aiReformulate(text: string, option: string, t: any, language?: string): Promise { @@ -497,7 +492,6 @@ export const RichTextEditor = forwardRef { - const { t } = useLanguage() - const router = useRouter() - const noteId = node.attrs.noteId as string - const title = (node.attrs.title as string) || t('notes.untitled') || 'Sans titre' - - const open = () => { - if (noteId) router.push(`/home?openNote=${noteId}`) - } - - return ( - -
-
- -
-
-
{title}
-
Sous-page
-
- - -
-
- ) -} - -export const SubPageExtension = Node.create({ - name: 'subPageBlock', - group: 'block', - atom: true, - defining: true, - - addAttributes() { - return { - noteId: { - default: '', - parseHTML: (el) => el.getAttribute('data-note-id') || '', - renderHTML: (attrs) => ({ 'data-note-id': attrs.noteId }), - }, - title: { - default: '', - parseHTML: (el) => el.getAttribute('data-title') || '', - renderHTML: (attrs) => ({ 'data-title': attrs.title }), - }, - } - }, - - parseHTML() { - return [{ tag: 'div[data-type="sub-page-block"]' }] - }, - - renderHTML({ node, HTMLAttributes }) { - return [ - 'div', - mergeAttributes(HTMLAttributes, { - 'data-type': 'sub-page-block', - 'data-note-id': node.attrs.noteId, - 'data-title': node.attrs.title, - class: 'sub-page-block', - }), - ] - }, - - addNodeView() { - return ReactNodeViewRenderer(SubPageView) - }, - - addKeyboardShortcuts() { - return { - 'Mod-Shift-P': () => this.editor.commands.insertContent({ - type: this.name, - attrs: { noteId: '', title: '' }, - }), - } - }, -}) - -export async function insertSubPageBlock(editor: any): Promise { - const notebookId = (editor.storage as any).structuredViewBlock?.notebookId as string | null - - try { - const res = await fetch('/api/notes', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: 'Sans titre', - content: '

', - notebookId: notebookId || undefined, - }), - }) - const data = await res.json() - - if (!res.ok || !data.data) return - - const note = data.data - editor.chain().focus().insertContent({ - type: 'subPageBlock', - attrs: { - noteId: note.id, - title: note.title || 'Sans titre', - }, - }).run() - } catch (e) { - console.error('[SubPage] Failed to create note:', e) - } -} diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 821a74e..1d6ab6b 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -2583,6 +2583,58 @@ "structuredViewsImportCsv": "Import CSV file", "structuredViewsExportCsv": "Export as CSV", "structuredViewsOrganizer": "Organize", + "generalContinue": "Continue", + "structuredViewsTagApplied": "applied", + "wizard": { + "title": "Create a notebook with AI", + "chooseProfile": "What's your profile?", + "profileStudent": "Student", + "profileStudentDesc": "AI creates a structured course notebook with summaries, formulas and key takeaways", + "profileTeacher": "Teacher", + "profileTeacherDesc": "AI generates a course structure with chapters, exercises and learning objectives", + "profileEngineer": "Engineer / Professional", + "profileEngineerDesc": "AI creates organized technical documentation with specs and references", + "topicStudentPlaceholder": "e.g: Thermodynamics, Calculus, French Revolution...", + "topicTeacherPlaceholder": "e.g: Math 101, AP Physics, Algorithms...", + "topicEngineerPlaceholder": "e.g: Microservices architecture, Network security, ISO 27001...", + "topic": "Topic", + "level": "Level", + "levelBeginner": "Beginner", + "levelIntermediate": "Intermediate", + "levelAdvanced": "Advanced", + "levelExpert": "Expert", + "noteCount": "Number of notes", + "notes": "notes", + "confirmHint": "AI will create a notebook with rich notes: callouts, collapsible sections, math formulas, comparison columns, outlines and links.", + "generate": "Generate notebook", + "loading": "AI is creating your notebook with structured notes...", + "progressGenerating": "Generating content with AI...", + "progressCalling": "Calling AI...", + "progressParsing": "Parsing AI response...", + "progressCreating": "Creating notebook and notes...", + "success": "Notebook created successfully!", + "created": "Notebook created:", + "notesCreated": "notes created", + "openNotebook": "Open notebook", + "studyPlanner": "Study Plan", + "studyPlannerDesc": "AI creates a revision plan based on spaced repetition.", + "examDate": "Exam date", + "generatePlan": "Generate plan", + "studyPlanLoading": "Creating plan...", + "studyPlanSuccess": "Plan created! Reminders have been added to your notes.", + "daysPlanned": "days planned", + "studyPlanReminders": "Reminders have been automatically added to your notes.", + "organizer": "Organize with AI", + "organizerDesc": "AI analyzes your notes and suggests tags, groupings and duplicate detection.", + "analyze": "Analyze notebook", + "organizing": "Analyzing notes...", + "suggestedTags": "Suggested tags", + "categories": "Suggested groupings", + "duplicates": "Duplicates detected", + "apply": "Apply", + "tagApplied": "applied", + "noSuggestions": "No suggestions — notebook looks well organized." + }, "wizardOrganizer": "Organize with AI", "wizardOrganizerDesc": "AI analyzes your notes and suggests tags, groupings and duplicate detection.", "wizardAnalyze": "Analyze notebook", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index add9527..fb97b1c 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -2587,6 +2587,58 @@ "structuredViewsImportCsv": "Importer un fichier CSV", "structuredViewsExportCsv": "Exporter en CSV", "structuredViewsOrganizer": "Organiser", + "generalContinue": "Continuer", + "structuredViewsTagApplied": "appliqué", + "wizard": { + "title": "Créer un carnet avec l'IA", + "chooseProfile": "Quel est votre profil ?", + "profileStudent": "Étudiant", + "profileStudentDesc": "L'IA crée un carnet de cours structuré avec résumés, formules et encadrés à retenir", + "profileTeacher": "Professeur", + "profileTeacherDesc": "L'IA génère la structure d'un cours avec chapitres, exercices et objectifs pédagogiques", + "profileEngineer": "Ingénieur / Professionnel", + "profileEngineerDesc": "L'IA crée une documentation technique organisée avec spécifications et références", + "topicStudentPlaceholder": "Ex: Thermodynamique, Calcul différentiel, Histoire de la Révolution...", + "topicTeacherPlaceholder": "Ex: Mathématiques L1, Physique-Chimie Terminale, Algorithmique...", + "topicEngineerPlaceholder": "Ex: Architecture microservices, Sécurité réseau, Norme ISO 27001...", + "topic": "Sujet", + "level": "Niveau", + "levelBeginner": "Débutant", + "levelIntermediate": "Intermédiaire", + "levelAdvanced": "Avancé", + "levelExpert": "Expert", + "noteCount": "Nombre de notes", + "notes": "notes", + "confirmHint": "L'IA va créer un carnet avec des notes riches : encadrés, sections repliables, formules mathématiques, colonnes de comparaison, sommaires et liens.", + "generate": "Générer le carnet", + "loading": "L'IA crée votre carnet avec des notes structurées...", + "progressGenerating": "Génération du contenu par l'IA...", + "progressCalling": "Appel de l'IA...", + "progressParsing": "Analyse de la réponse de l'IA...", + "progressCreating": "Création du carnet et des notes...", + "success": "Carnet créé avec succès !", + "created": "Carnet créé :", + "notesCreated": "notes créées", + "openNotebook": "Ouvrir le carnet", + "studyPlanner": "Planning de révision", + "studyPlannerDesc": "L'IA crée un planning de révision basé sur la répétition espacée.", + "examDate": "Date de l'examen", + "generatePlan": "Générer le planning", + "studyPlanLoading": "Création du planning...", + "studyPlanSuccess": "Planning créé ! Des rappels ont été ajoutés à vos notes.", + "daysPlanned": "jours planifiés", + "studyPlanReminders": "Des rappels ont été ajoutés automatiquement à vos notes.", + "organizer": "Organiser avec l'IA", + "organizerDesc": "L'IA analyse vos notes et propose tags, regroupements et détection de doublons.", + "analyze": "Analyser le carnet", + "organizing": "Analyse des notes en cours...", + "suggestedTags": "Tags suggérés", + "categories": "Regroupements suggérés", + "duplicates": "Doublons détectés", + "apply": "Appliquer", + "tagApplied": "appliqué", + "noSuggestions": "Aucune suggestion — le carnet semble bien organisé." + }, "wizardOrganizer": "Organiser avec l'IA", "wizardOrganizerDesc": "L'IA analyse vos notes et propose tags, regroupements et détection de doublons.", "wizardAnalyze": "Analyser le carnet", diff --git a/memento-note/memento-note/lib/ai/services/bridge-notes.service.ts b/memento-note/memento-note/lib/ai/services/bridge-notes.service.ts deleted file mode 100644 index e95ab4e..0000000 --- a/memento-note/memento-note/lib/ai/services/bridge-notes.service.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * Bridge Notes Service - * - * Detects notes that connect multiple clusters (bridge notes) - * and generates AI-powered suggestions for missing connections. - */ - -import prisma from '@/lib/prisma' - -export interface BridgeNote { - noteId: string - bridgeScore: number - clustersConnected: number[] - clusterNames?: string[] -} - -export interface ConnectionSuggestion { - clusterAId: number - clusterBId: number - clusterAName: string - clusterBName: string - suggestedTitle: string - suggestedContent: string - justification: string -} - -export class BridgeNotesService { - private readonly BRIDGE_THRESHOLD = 0.5 // Cosine similarity threshold - - /** - * Detect bridge notes for a user. - * A bridge note is a note that has strong connections (>= 0.5 similarity) - * to at least 2 different clusters. - */ - async detectBridgeNotes(userId: string): Promise { - // Get all clusters for the user - const clusters = await prisma.noteCluster.findMany({ - where: { userId }, - select: { clusterId: true, name: true } - }) - - if (clusters.length < 2) return [] - - // Get cluster memberships - const clusterMembers = await prisma.clusterMember.findMany({ - where: { userId }, - select: { noteId: true, clusterId: true } - }) - - // Group notes by cluster - const notesByCluster = new Map() - for (const cluster of clusters) { - notesByCluster.set( - cluster.clusterId, - clusterMembers - .filter(cm => cm.clusterId === cluster.clusterId) - .map(cm => cm.noteId) - ) - } - - const bridgeNotes: BridgeNote[] = [] - const processedNotes = new Set() - - // For each note, check if it connects to multiple clusters - for (const [clusterId, noteIds] of notesByCluster) { - for (const noteId of noteIds) { - if (processedNotes.has(noteId)) continue - processedNotes.add(noteId) - - // Check which other clusters this note is similar to - const connectedClusters: number[] = [] - - for (const [otherClusterId, otherNoteIds] of notesByCluster) { - if (otherClusterId === clusterId) continue - - // Check similarity to notes in other cluster - const hasStrongConnection = await this.hasStrongLinkToCluster( - noteId, - otherNoteIds - ) - - if (hasStrongConnection) { - connectedClusters.push(otherClusterId) - } - } - - // If connected to >= 2 clusters, it's a bridge note - if (connectedClusters.length >= 1) { - // Include the original cluster - connectedClusters.unshift(clusterId) - - bridgeNotes.push({ - noteId, - bridgeScore: connectedClusters.length / Math.max(clusters.length, 1), - clustersConnected: connectedClusters, - clusterNames: connectedClusters - .map(id => clusters.find(c => c.clusterId === id)?.name) - .filter(Boolean) as string[] - }) - } - } - } - - return bridgeNotes.sort((a, b) => b.bridgeScore - a.bridgeScore) - } - - /** - * Check if a note has strong links (similarity >= threshold) to any note in a cluster. - */ - private async hasStrongLinkToCluster( - noteId: string, - clusterNoteIds: string[] - ): Promise { - if (clusterNoteIds.length === 0) return false - - for (const otherNoteId of clusterNoteIds) { - const similarity = await this.getCosineSimilarity(noteId, otherNoteId) - if (similarity >= this.BRIDGE_THRESHOLD) { - return true - } - } - - return false - } - - /** - * Get cosine similarity between two notes using pgvector. - */ - private async getCosineSimilarity( - noteIdA: string, - noteIdB: string - ): Promise { - const result = await prisma.$queryRawUnsafe>( - `SELECT 1 - (e1."embedding"::vector <=> e2."embedding"::vector) AS similarity - FROM "NoteEmbedding" e1, "NoteEmbedding" e2 - WHERE e1."noteId" = $1 AND e2."noteId" = $2`, - noteIdA, - noteIdB - ) - return result[0]?.similarity || 0 - } - - /** - * Get saved bridge notes for a user. - */ - async getBridgeNotes(userId: string): Promise { - const bridges = await prisma.bridgeNote.findMany({ - where: { userId }, - include: { - clusters: { - include: { - cluster: { - select: { name: true } - } - } - } - } - }) - - return bridges.map(b => ({ - noteId: b.noteId, - bridgeScore: b.bridgeScore, - clustersConnected: b.clusters.map(c => c.clusterId), - clusterNames: b.clusters.map(c => c.cluster.name) - })) - } - - /** - * Save bridge notes to the database. - */ - async saveBridgeNotes(userId: string, bridgeNotes: BridgeNote[]): Promise { - await prisma.$transaction(async (tx) => { - // Clear existing bridge notes for this user - await tx.$executeRawUnsafe(`DELETE FROM "BridgeNoteCluster" WHERE "userId" = $1`, userId) - await tx.bridgeNote.deleteMany({ where: { userId } }) - - // Insert new bridge notes - for (const bridge of bridgeNotes) { - await tx.bridgeNote.create({ - data: { - userId, - noteId: bridge.noteId, - bridgeScore: bridge.bridgeScore, - clusters: { - create: bridge.clustersConnected.map(clusterId => ({ - userId, - clusterId - })) - } - } - }) - } - }) - } - - /** - * Generate AI-powered suggestions for connecting isolated clusters. - */ - async generateConnectionSuggestions( - userId: string - ): Promise { - const clusters = await prisma.noteCluster.findMany({ - where: { userId }, - select: { clusterId: true, name: true } - }) - - if (clusters.length < 2) return [] - - const suggestions: ConnectionSuggestion[] = [] - - // Generate suggestions for cluster pairs (limit to 5 pairs) - for (let i = 0; i < Math.min(clusters.length, 3); i++) { - for (let j = i + 1; j < Math.min(clusters.length, 4); j++) { - const clusterA = clusters[i] - const clusterB = clusters[j] - - // Get sample notes from each cluster - const notesA = await prisma.$queryRawUnsafe< - Array<{ title: string | null; content: string }> - >( - `SELECT n.title, n.content - FROM "ClusterMember" cm - INNER JOIN "Note" n ON n.id = cm."noteId" - WHERE cm."clusterId" = $1 AND cm."userId" = $2 - LIMIT 3`, - clusterA.clusterId, - userId - ) - - const notesB = await prisma.$queryRawUnsafe< - Array<{ title: string | null; content: string }> - >( - `SELECT n.title, n.content - FROM "ClusterMember" cm - INNER JOIN "Note" n ON n.id = cm."noteId" - WHERE cm."clusterId" = $1 AND cm."userId" = $2 - LIMIT 3`, - clusterB.clusterId, - userId - ) - - const summaryA = notesA.map(n => n.title || 'Untitled').join(', ') - const summaryB = notesB.map(n => n.title || 'Untitled').join(', ') - - const suggestion = await this.generateBridgeSuggestion( - clusterA.name || `Cluster ${clusterA.clusterId}`, - clusterB.name || `Cluster ${clusterB.clusterId}`, - summaryA, - summaryB - ) - - suggestions.push({ - clusterAId: clusterA.clusterId, - clusterBId: clusterB.clusterId, - clusterAName: clusterA.name || `Cluster ${clusterA.clusterId}`, - clusterBName: clusterB.name || `Cluster ${clusterB.clusterId}`, - ...suggestion - }) - } - } - - return suggestions - } - - /** - * Generate a single bridge suggestion using the LLM. - */ - private async generateBridgeSuggestion( - clusterAName: string, - clusterBName: string, - summaryA: string, - summaryB: string - ): Promise> { - const prompt = `Cluster A ("${clusterAName}") contains notes about: ${summaryA} -Cluster B ("${clusterBName}") contains notes about: ${summaryB} - -These clusters are not directly connected. Suggest ONE creative "bridge note" idea that could connect them. - -Provide your response as a JSON object with these fields: -- title: A concise title for the bridge note (2-6 words) -- description: What this note would explore (1-2 sentences) -- justification: Why this connection makes sense (1 sentence) - -JSON:` - - try { - const { getChatProvider } = await import('@/lib/ai/factory') - const { getSystemConfig } = await import('@/lib/config') - - const config = await getSystemConfig() - const provider = getChatProvider(config) - const response = await provider.chat([{ role: 'user', content: prompt }], '') - - const text = response.text.trim() - const jsonMatch = text.match(/\{[\s\S]*\}/) - - if (jsonMatch) { - return JSON.parse(jsonMatch[0]) - } - - // Fallback if JSON parsing fails - return { - suggestedTitle: `Connecting ${clusterAName} and ${clusterBName}`, - suggestedContent: `Explore the relationships between concepts from ${clusterAName} and ${clusterBName}.`, - justification: 'These topics may share underlying principles or applications.' - } - } catch { - return { - suggestedTitle: `Connecting ${clusterAName} and ${clusterBName}`, - suggestedContent: `Explore the relationships between concepts from ${clusterAName} and ${clusterBName}.`, - justification: 'These topics may share underlying principles or applications.' - } - } - } - - /** - * Dismiss a connection suggestion. - */ - async dismissSuggestion(userId: string, clusterAId: number, clusterBId: number): Promise { - await prisma.bridgeSuggestion.deleteMany({ - where: { - userId, - clusterAId, - clusterBId - } - }) - } -} - -export const bridgeNotesService = new BridgeNotesService() diff --git a/memento-note/memento-note/lib/ai/services/clustering.service.ts b/memento-note/memento-note/lib/ai/services/clustering.service.ts deleted file mode 100644 index 41c0755..0000000 --- a/memento-note/memento-note/lib/ai/services/clustering.service.ts +++ /dev/null @@ -1,410 +0,0 @@ -/** - * Clustering Service - * - * Density-based clustering algorithm (DBSCAN variant) for note embeddings. - * Groups semantically similar notes into clusters without requiring - * a preset number of clusters. - */ - -import prisma from '@/lib/prisma' -import { embeddingService } from './embedding.service' -import { getChatProvider } from '@/lib/ai/factory' -import { getSystemConfig } from '@/lib/config' - -export interface ClusterResult { - clusterId: number - noteIds: string[] - centroid?: number[] - name?: string -} - -export interface ClusteredNote { - noteId: string - clusterId: number - membershipScore: number - isCentral: boolean -} - -export interface ClusteringOptions { - minClusterSize?: number - epsilon?: number // Cosine distance threshold (lower = more strict) - maxClusters?: number -} - -export class ClusteringService { - private readonly DEFAULT_MIN_CLUSTER_SIZE = 3 - private readonly DEFAULT_EPSILON = 0.3 // Cosine distance ~ 1 - similarity - private readonly DEFAULT_MAX_CLUSTERS = 50 - private readonly MIN_NOTES_FOR_CLUSTERING = 10 - - /** - * Calculate cosine similarity between two note IDs using pgvector. - */ - private async getCosineSimilarity( - noteIdA: string, - noteIdB: string - ): Promise { - const result = await prisma.$queryRawUnsafe>( - `SELECT 1 - (e1."embedding"::vector <=> e2."embedding"::vector) AS similarity - FROM "NoteEmbedding" e1, "NoteEmbedding" e2 - WHERE e1."noteId" = $1 AND e2."noteId" = $2`, - noteIdA, - noteIdB - ) - return result[0]?.similarity || 0 - } - - /** - * Find all neighbors for a note within epsilon similarity threshold. - */ - private async findNeighbors( - noteId: string, - allNoteIds: string[], - epsilon: number - ): Promise { - const cosineDistance = 1 - epsilon - - const result = await prisma.$queryRawUnsafe>( - `SELECT e2."noteId" - FROM "NoteEmbedding" e1 - CROSS JOIN "NoteEmbedding" e2 - WHERE e1."noteId" = $1 - AND e2."noteId" != $1 - AND e2."noteId" = ANY($2::text[]) - AND (e1."embedding"::vector <=> e2."embedding"::vector) <= $3`, - noteId, - allNoteIds, - cosineDistance - ) - - return result.map(r => r.noteId) - } - - /** - * Expand a cluster from a seed note using DBSCAN-like algorithm. - */ - private async expandCluster( - noteId: string, - neighbors: string[], - clusterId: number, - visited: Set, - clustered: Map, - allNoteIds: string[], - epsilon: number, - minClusterSize: number - ): Promise { - const clusterMembers: string[] = [noteId] - const queue = [...neighbors] - clustered.set(noteId, clusterId) - - while (queue.length > 0) { - const currentNoteId = queue.shift()! - - if (!visited.has(currentNoteId)) { - visited.add(currentNoteId) - const currentNeighbors = await this.findNeighbors(currentNoteId, allNoteIds, epsilon) - - if (currentNeighbors.length >= minClusterSize) { - for (const neighborId of currentNeighbors) { - if (!clustered.has(neighborId)) { - clustered.set(neighborId, clusterId) - clusterMembers.push(neighborId) - queue.push(neighborId) - } - } - } - } - } - - return clusterMembers - } - - /** - * Perform density-based clustering on user's note embeddings. - */ - async clusterNotes( - userId: string, - options: ClusteringOptions = {} - ): Promise<{ - clusters: ClusterResult[] - clusteredNotes: ClusteredNote[] - noiseCount: number - }> { - const { - minClusterSize = this.DEFAULT_MIN_CLUSTER_SIZE, - epsilon = this.DEFAULT_EPSILON, - maxClusters = this.DEFAULT_MAX_CLUSTERS - } = options - - // Get all user's notes with embeddings - const notesWithEmbeddings = await prisma.$queryRawUnsafe>( - `SELECT ne."noteId" - FROM "NoteEmbedding" ne - INNER JOIN "Note" n ON n.id = ne."noteId" - WHERE n."userId" = $1 - AND n."trashedAt" IS NULL - AND ne."embedding" IS NOT NULL`, - userId - ) - - const allNoteIds = notesWithEmbeddings.map(n => n.noteId) - - if (allNoteIds.length < this.MIN_NOTES_FOR_CLUSTERING) { - return { - clusters: [], - clusteredNotes: [], - noiseCount: allNoteIds.length - } - } - - const visited = new Set() - const clustered = new Map() - const clusterResults: ClusterResult[] = [] - let clusterId = 0 - - // DBSCAN algorithm - for (const noteId of allNoteIds) { - if (visited.has(noteId)) continue - - visited.add(noteId) - const neighbors = await this.findNeighbors(noteId, allNoteIds, epsilon) - - if (neighbors.length < minClusterSize) { - clustered.set(noteId, -1) - continue - } - - // Expand cluster - const clusterMembers = await this.expandCluster( - noteId, - neighbors, - clusterId, - visited, - clustered, - allNoteIds, - epsilon, - minClusterSize - ) - - if (clusterMembers.length >= minClusterSize && clusterId < maxClusters) { - clusterResults.push({ - clusterId, - noteIds: clusterMembers - }) - clusterId++ - } else { - // Too small, mark as noise - for (const memberId of clusterMembers) { - clustered.set(memberId, -1) - } - } - } - - // Calculate membership scores and identify central notes - const clusteredNotes: ClusteredNote[] = [] - for (const [noteId, cid] of clustered.entries()) { - if (cid === -1) continue - - const cluster = clusterResults[cid] - if (!cluster) continue - - const score = await this.calculateMembershipScore(noteId, cluster.noteIds) - const isCentral = await this.isCentralNote(noteId, cluster.noteIds) - - clusteredNotes.push({ - noteId, - clusterId: cid, - membershipScore: score, - isCentral - }) - } - - const noiseCount = Array.from(clustered.values()).filter(id => id === -1).length - - return { - clusters: clusterResults, - clusteredNotes, - noiseCount - } - } - - /** - * Calculate membership score for a note within its cluster. - */ - private async calculateMembershipScore(noteId: string, clusterMemberIds: string[]): Promise { - if (clusterMemberIds.length <= 1) return 1.0 - - const similarities: number[] = [] - for (const memberId of clusterMemberIds) { - if (memberId === noteId) continue - const sim = await this.getCosineSimilarity(noteId, memberId) - similarities.push(sim) - } - - return similarities.length > 0 - ? similarities.reduce((a, b) => a + b, 0) / similarities.length - : 1.0 - } - - /** - * Determine if a note is central to its cluster. - */ - private async isCentralNote(noteId: string, clusterMemberIds: string[]): Promise { - const allScores: Array<{ memberId: string; score: number }> = [] - - for (const memberId of clusterMemberIds) { - const score = await this.calculateMembershipScore(memberId, clusterMemberIds) - allScores.push({ memberId, score }) - } - - const meanScore = allScores.reduce((sum, s) => sum + s.score, 0) / allScores.length - const noteScore = allScores.find(s => s.memberId === noteId)?.score || 0 - - return noteScore >= meanScore - } - - /** - * Save clustering results to database. - */ - async saveClusteringResults( - userId: string, - results: { clusters: ClusterResult[]; clusteredNotes: ClusteredNote[] } - ): Promise { - await prisma.$transaction(async (tx) => { - // Clear existing clusters for this user - await tx.$executeRawUnsafe(`DELETE FROM "ClusterMember" WHERE "userId" = $1`, userId) - await tx.$executeRawUnsafe(`DELETE FROM "NoteCluster" WHERE "userId" = $1`, userId) - - // Insert new clusters - for (const cluster of results.clusters) { - await tx.noteCluster.create({ - data: { - userId, - clusterId: cluster.clusterId, - name: cluster.name, - noteCount: cluster.noteIds.length, - lastCalculated: new Date() - } - }) - } - - // Insert cluster members - for (const clusteredNote of results.clusteredNotes) { - await tx.clusterMember.create({ - data: { - userId, - noteId: clusteredNote.noteId, - clusterId: clusteredNote.clusterId, - membershipScore: clusteredNote.membershipScore, - isCentral: clusteredNote.isCentral - } - }) - } - }) - } - - /** - * Generate a name for a cluster using the LLM. - */ - async generateClusterName(clusterId: number, userId: string): Promise { - const centralNotes = await prisma.$queryRawUnsafe>( - `SELECT DISTINCT n.id AS "noteId", n.title, n.content - FROM "ClusterMember" cm - INNER JOIN "Note" n ON n.id = cm."noteId" - WHERE cm."clusterId" = $1 - AND cm."userId" = $2 - AND cm."isCentral" = true - LIMIT 5`, - clusterId, - userId - ) - - if (centralNotes.length === 0) { - return `Cluster ${clusterId}` - } - - const notesText = centralNotes - .map((note, i) => `${i + 1}. "${note.title || 'Untitled'}" - ${note.content.slice(0, 100)}...`) - .join('\n') - - const systemPrompt = 'You are a clustering assistant. Provide ONLY a concise name (2-4 words) in English. No punctuation, no explanation.' - - const userPrompt = `Analyze these 5 notes that belong to the same cluster. What is the common theme?\n\n${notesText}\n\nTheme:` - - try { - const config = await getSystemConfig() - const provider = getChatProvider(config) - const response = await provider.chat( - [{ role: 'user', content: userPrompt }], - systemPrompt - ) - return response.text.trim().slice(0, 50) - } catch { - return `Cluster ${clusterId}` - } - } - - /** - * Check if recalculation is needed based on data change percentage. - */ - async shouldRecalculate(userId: string): Promise { - const lastCluster = await prisma.noteCluster.findFirst({ - where: { userId }, - orderBy: { lastCalculated: 'desc' } - }) - - if (!lastCluster) return true - - const modifiedCount = await prisma.note.count({ - where: { - userId, - OR: [ - { updatedAt: { gt: lastCluster.lastCalculated } }, - { contentUpdatedAt: { gt: lastCluster.lastCalculated } } - ] - } - }) - - const totalNotes = await prisma.note.count({ - where: { userId, trashedAt: null } - }) - - if (totalNotes === 0) return false - - const changePercentage = modifiedCount / totalNotes - return changePercentage > 0.05 - } - - /** - * Get cached clustering results if available and fresh. - */ - async getCachedClusters(userId: string): Promise { - const clusters = await prisma.noteCluster.findMany({ - where: { userId }, - orderBy: { clusterId: 'asc' } - }) - - if (clusters.length === 0) return null - - const needsUpdate = await this.shouldRecalculate(userId) - if (needsUpdate) return null - - const result: ClusterResult[] = [] - for (const cluster of clusters) { - const members = await prisma.clusterMember.findMany({ - where: { clusterId: cluster.clusterId, userId }, - select: { noteId: true } - }) - - result.push({ - clusterId: cluster.clusterId, - noteIds: members.map(m => m.noteId), - name: cluster.name || undefined - }) - } - - return result - } -} - -export const clusteringService = new ClusteringService()