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 }, where: { notebookId, trashedAt: null, userId: session.user.id },
select: { id: true, title: true }, select: { id: true, title: true },
orderBy: { order: 'asc' }, orderBy: { order: 'asc' },
}) }) as Array<{ id: string; title: string | null }>
if (notes.length === 0) { if (notes.length === 0) {
return NextResponse.json({ error: 'No notes found in notebook' }, { status: 400 }) return NextResponse.json({ error: 'No notes found in notebook' }, { status: 400 })
@@ -44,7 +44,8 @@ export async function POST(request: NextRequest) {
select: { theme: true }, 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 // Set reminders on notes based on the plan
for (const day of plan.days) { 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 headers = parseCSVLine(lines[0].replace(/^\uFEFF/, ''))
const titleIdx = headers.findIndex(h => h.toLowerCase().includes('titre') || h.toLowerCase() === 'title') 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 // Get or create schema
let schema = await prisma.notebookSchema.findUnique({ where: { notebookId } }) let schema = await prisma.notebookSchema.findUnique({ where: { notebookId } })

View File

@@ -315,6 +315,7 @@ export function OrganizeNotebookDialog({
)} )}
</div> </div>
</motion.div> </motion.div>
</motion.div>
</> </>
)} )}
</AnimatePresence> </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 { LinkPreviewExtension, insertLinkPreview, isUrl } from './tiptap-link-preview-extension'
import { MathEquationExtension, InlineMathExtension, insertMathEquation } from './tiptap-math-extension' import { MathEquationExtension, InlineMathExtension, insertMathEquation } from './tiptap-math-extension'
import { ColumnsExtension, ColumnNode, insertColumnsBlock } from './tiptap-columns-extension' import { ColumnsExtension, ColumnNode, insertColumnsBlock } from './tiptap-columns-extension'
import { SubPageExtension, insertSubPageBlock } from './tiptap-subpage-extension'
import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension' import { RtlPreserveExtension } from './tiptap-rtl-preserve-extension'
import { ClipArticleExtension } from './tiptap-clip-article-extension' import { ClipArticleExtension } from './tiptap-clip-article-extension'
import { BlockPicker, type BlockSuggestion } from './block-picker' import { BlockPicker, type BlockSuggestion } from './block-picker'
@@ -70,7 +69,7 @@ import {
FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight, FileText, Pilcrow, MessageSquare, AlignLeft, AlignCenter, AlignRight,
Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus, Superscript as SuperscriptIcon, Subscript as SubscriptIcon, Expand, Plus,
SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database, SpellCheck, Languages, BookOpen, Presentation, BarChart3, Database,
ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2, FileOutput ChevronsRightLeft, MessageSquareWarning, ListTree, FunctionSquare, Columns3, Loader2
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { toast } from 'sonner' 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: 'É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> { async function aiReformulate(text: string, option: string, t: any, language?: string): Promise<string> {
@@ -497,7 +492,6 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorPro
InlineMathExtension, InlineMathExtension,
ColumnsExtension, ColumnsExtension,
ColumnNode, ColumnNode,
SubPageExtension,
ClipArticleExtension, ClipArticleExtension,
RtlPreserveExtension, RtlPreserveExtension,
Placeholder.configure({ 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[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[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[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'), title: t('richTextEditor.slashNoteLink'),
description: t('richTextEditor.slashNoteLinkDesc'), description: t('richTextEditor.slashNoteLinkDesc'),
@@ -1843,11 +1836,6 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
if (!insertStructuredViewBlockAtSelection(editor, currentNotebookId)) { if (!insertStructuredViewBlockAtSelection(editor, currentNotebookId)) {
toast.error(t('structuredViewBlock.loadError') || 'Impossible de charger les données structurées.') 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 { } else {
deleteSlashText(); item.command(editor); closeMenu() deleteSlashText(); item.command(editor); closeMenu()
} }

View File

@@ -3,7 +3,7 @@
import { Node, mergeAttributes, findParentNode } from '@tiptap/core' import { Node, mergeAttributes, findParentNode } from '@tiptap/core'
import { TextSelection } from '@tiptap/pm/state' import { TextSelection } from '@tiptap/pm/state'
import type { EditorState, Transaction } 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({ export const ColumnNode = Node.create({
name: 'column', 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", "structuredViewsImportCsv": "Import CSV file",
"structuredViewsExportCsv": "Export as CSV", "structuredViewsExportCsv": "Export as CSV",
"structuredViewsOrganizer": "Organize", "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", "wizardOrganizer": "Organize with AI",
"wizardOrganizerDesc": "AI analyzes your notes and suggests tags, groupings and duplicate detection.", "wizardOrganizerDesc": "AI analyzes your notes and suggests tags, groupings and duplicate detection.",
"wizardAnalyze": "Analyze notebook", "wizardAnalyze": "Analyze notebook",

View File

@@ -2587,6 +2587,58 @@
"structuredViewsImportCsv": "Importer un fichier CSV", "structuredViewsImportCsv": "Importer un fichier CSV",
"structuredViewsExportCsv": "Exporter en CSV", "structuredViewsExportCsv": "Exporter en CSV",
"structuredViewsOrganizer": "Organiser", "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", "wizardOrganizer": "Organiser avec l'IA",
"wizardOrganizerDesc": "L'IA analyse vos notes et propose tags, regroupements et détection de doublons.", "wizardOrganizerDesc": "L'IA analyse vos notes et propose tags, regroupements et détection de doublons.",
"wizardAnalyze": "Analyser le carnet", "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()