cleanup: audit complet — code mort supprimé, erreurs TS corrigées, i18n wizard ajouté
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:
@@ -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) {
|
||||||
|
|||||||
@@ -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 } })
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ export function OrganizeNotebookDialog({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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()
|
|
||||||
Reference in New Issue
Block a user