cleanup: audit complet — code mort supprimé, erreurs TS corrigées, i18n wizard ajouté
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m0s
CI / Deploy production (on server) (push) Successful in 1m2s

Supprimé:
- memento-note/memento-note/ (dossier fantôme, 7 erreurs TS)
- tiptap-subpage-extension.tsx + toutes ses références (feature retirée)

Corrigé:
- tiptap-columns-extension.tsx: PMNode import type → import value
- study-plan/route.ts: title null → string conversion
- csv/route.ts: paramètre implicit any

Ajouté:
- Section wizard.* complète (33 clés) dans fr.json + en.json
- generalContinue + structuredViewsTagApplied dans fr/en
This commit is contained in:
Antigravity
2026-06-19 21:53:10 +00:00
parent 723f7ef432
commit 299836bd74
10 changed files with 111 additions and 879 deletions

View File

@@ -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) {

View File

@@ -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 } })

View File

@@ -315,6 +315,7 @@ export function OrganizeNotebookDialog({
)}
</div>
</motion.div>
</motion.div>
</>
)}
</AnimatePresence>

View File

@@ -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<string> {
@@ -497,7 +492,6 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
InlineMathExtension,
ColumnsExtension,
ColumnNode,
SubPageExtension,
ClipArticleExtension,
RtlPreserveExtension,
Placeholder.configure({
@@ -1731,7 +1725,6 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
{ ...slashCommands[35], title: t('richTextEditor.slashMath'), description: t('richTextEditor.slashMathDesc'), categoryId: 'text', slashKeywords: ['math', 'maths', 'equation', 'équation', 'formula', 'formule', 'latex', 'katex'] },
{ ...slashCommands[36], title: t('richTextEditor.slashColumns'), description: t('richTextEditor.slashColumnsDesc'), categoryId: 'text', slashKeywords: ['columns', 'colonnes', 'cols', 'layout', 'mise', 'page', 'cote', 'côte'] },
{ ...slashCommands[37], title: t('richTextEditor.slashAiWriter') || 'Écrire avec l\'IA', description: t('richTextEditor.slashAiWriterDesc') || 'Générer du contenu au curseur', categoryId: 'ai', slashKeywords: ['ecrire', 'écrire', 'write', 'ia', 'ai', 'generer', 'générer', 'rediger', 'rédiger'] },
{ ...slashCommands[38], title: t('richTextEditor.slashSubPage') || 'Sous-page', description: t('richTextEditor.slashSubPageDesc') || 'Créer une note liée', categoryId: 'text', slashKeywords: ['sub', 'subpage', 'sous-page', 'souspage', 'page', 'lien', 'nested', 'imbriquée'] },
{
title: t('richTextEditor.slashNoteLink'),
description: t('richTextEditor.slashNoteLinkDesc'),
@@ -1843,11 +1836,6 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
if (!insertStructuredViewBlockAtSelection(editor, currentNotebookId)) {
toast.error(t('structuredViewBlock.loadError') || 'Impossible de charger les données structurées.')
}
} else if (item.title === 'Sub-page') {
deleteSlashText(); closeMenu()
toast.loading('Création de la sous-page...', { id: 'subpage' })
await insertSubPageBlock(editor)
toast.success('Sous-page créée !', { id: 'subpage' })
} else {
deleteSlashText(); item.command(editor); closeMenu()
}

View File

@@ -3,7 +3,7 @@
import { Node, mergeAttributes, findParentNode } from '@tiptap/core'
import { TextSelection } from '@tiptap/pm/state'
import type { EditorState, Transaction } from '@tiptap/pm/state'
import type { Node as PMNode } from '@tiptap/pm/model'
import { Node as PMNode } from '@tiptap/pm/model'
export const ColumnNode = Node.create({
name: 'column',

View File

@@ -1,122 +0,0 @@
'use client'
import { Node, mergeAttributes } from '@tiptap/core'
import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'
import { FileText, Trash2, ChevronRight } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useRouter } from 'next/navigation'
const SubPageView = ({ node, deleteNode, selected }: any) => {
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 (
<NodeViewWrapper className="sub-page-block my-2" dir="auto" data-selected={selected}>
<div className="group/sub flex items-center gap-2 rounded-xl border border-border bg-card hover:border-brand-accent/40 hover:shadow-sm transition-all p-3 cursor-pointer" onClick={open}>
<div className="w-8 h-8 rounded-lg bg-brand-accent/10 flex items-center justify-center flex-shrink-0">
<FileText className="h-4 w-4 text-brand-accent" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{title}</div>
<div className="text-[10px] text-muted-foreground">Sous-page</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0 group-hover/sub:text-brand-accent transition-colors" />
<button
onClick={(e) => { e.stopPropagation(); deleteNode() }}
contentEditable={false}
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive opacity-0 group-hover/sub:opacity-100 transition-opacity flex-shrink-0"
title="Supprimer"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</NodeViewWrapper>
)
}
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<void> {
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: '<p></p>',
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)
}
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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<BridgeNote[]> {
// 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<number, string[]>()
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<string>()
// 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<boolean> {
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<number> {
const result = await prisma.$queryRawUnsafe<Array<{ similarity: number }>>(
`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<BridgeNote[]> {
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<void> {
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<ConnectionSuggestion[]> {
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<Omit<ConnectionSuggestion, 'clusterAId' | 'clusterBId' | 'clusterAName' | 'clusterBName'>> {
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<void> {
await prisma.bridgeSuggestion.deleteMany({
where: {
userId,
clusterAId,
clusterBId
}
})
}
}
export const bridgeNotesService = new BridgeNotesService()

View File

@@ -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<number> {
const result = await prisma.$queryRawUnsafe<Array<{ similarity: number }>>(
`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<string[]> {
const cosineDistance = 1 - epsilon
const result = await prisma.$queryRawUnsafe<Array<{ noteId: string }>>(
`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<string>,
clustered: Map<string, number>,
allNoteIds: string[],
epsilon: number,
minClusterSize: number
): Promise<string[]> {
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<Array<{ noteId: string }>>(
`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<string>()
const clustered = new Map<string, number>()
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<number> {
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<boolean> {
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<void> {
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<string> {
const centralNotes = await prisma.$queryRawUnsafe<Array<{ noteId: string; title: string | null; content: string }>>(
`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<boolean> {
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<ClusterResult[] | null> {
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()