fix: label system overhaul - sync dual storage, fix suggestions
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 53s

- Replace broken upsert (nbScope ?? '') with findFirst+create for null notebookId
- Remove aggressive orphan cleanup that deleted notebook labels after save
- Add syncNoteLabels() to update both Note.labels JSON and labelRelations
- Fix createNote, updateNote, auto-labeling to use syncNoteLabels
- Add fallback: suggestFromExistingLabels → suggestNewLabels if empty
- Lower confidence threshold 0.6→0.3, max suggestions 3→5
- Case-insensitive label matching in suggestions

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 00:00:53 +02:00
parent d906a77223
commit 0a4aa47690
2 changed files with 71 additions and 84 deletions

View File

@@ -99,85 +99,55 @@ function collectLabelNamesFromNote(note: {
}
/**
* Sync Label rows with Note.labels.
* Optimisé: createMany (bulk) + delete en parallèle — uniquement 3-4 requêtes au lieu de N+2.
* Upsert Label rows and return their IDs.
* No orphan cleanup — labels are only deleted via the label management dialog.
*/
async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?: string | null) {
async function syncLabels(userId: string, noteLabels: string[] = [], notebookId?: string | null): Promise<{ id: string; name: string }[]> {
try {
const nbScope = notebookId ?? null
// 1. Bulk-upsert les nouveaux labels via upsert en transaction
if (noteLabels.length > 0) {
const trimmedNames = [...new Set(
noteLabels.map(name => name?.trim()).filter((n): n is string => Boolean(n))
)]
if (trimmedNames.length > 0) {
await prisma.$transaction(
trimmedNames.map(name =>
prisma.label.upsert({
where: { notebookId_name: { notebookId: nbScope ?? '', name } as any },
update: {},
create: {
userId,
name,
color: getHashColor(name),
notebookId: nbScope,
},
})
)
)
for (const name of trimmedNames) {
const existing = await prisma.label.findFirst({
where: { userId, name, notebookId: nbScope },
})
if (!existing) {
await prisma.label.create({
data: { userId, name, color: getHashColor(name), notebookId: nbScope },
})
}
}
}
// 2. Récupérer les labels utilisés par toutes les notes de l'utilisateur
const [allNotes, allLabels] = await Promise.all([
prisma.note.findMany({
where: { userId },
select: {
notebookId: true,
labels: true,
labelRelations: { select: { name: true } },
},
}),
prisma.label.findMany({
where: { userId },
select: { id: true, name: true, notebookId: true },
})
])
const usedLabelsSet = new Set<string>()
for (const note of allNotes) {
for (const name of collectLabelNamesFromNote(note)) {
const key = labelScopeKey(note.notebookId, name)
if (key) usedLabelsSet.add(key)
}
}
// 3. Supprimer les labels orphelins
const orphanIds = allLabels
.filter(label => {
const key = labelScopeKey(label.notebookId, label.name)
return key && !usedLabelsSet.has(key)
})
.map(label => label.id)
if (orphanIds.length > 0) {
// Dissocier les relations avant la suppression
await prisma.label.updateMany({
where: { id: { in: orphanIds } },
data: {} // Nécessaire pour trigger le middleware
})
// Supprimer en une seule requête
await prisma.label.deleteMany({
where: { id: { in: orphanIds } }
})
}
if (noteLabels.length === 0) return []
const uniqueNames = [...new Set(noteLabels.map(n => n.trim().toLowerCase()).filter(Boolean))]
return prisma.label.findMany({
where: { userId, notebookId: nbScope, name: { in: uniqueNames } },
select: { id: true, name: true },
})
} catch (error) {
console.error('Fatal error in syncLabels:', error)
return []
}
}
/** Sync both Note.labels (JSON) AND labelRelations for a single note. */
async function syncNoteLabels(noteId: string, labelNames: string[], notebookId: string | null, userId: string) {
const uniqueNames = [...new Set(labelNames.map(n => n.trim()).filter(Boolean))]
const labelRows = await syncLabels(userId, uniqueNames, notebookId)
const labelIds = labelRows.map(l => l.id)
await prisma.note.update({
where: { id: noteId },
data: {
labels: uniqueNames.length > 0 ? JSON.stringify(uniqueNames) : null,
labelRelations: { set: labelIds.map(id => ({ id })) },
},
})
}
/** Après déplacement via API : rattacher les étiquettes de la note au bon carnet */
export async function reconcileLabelsAfterNoteMove(noteId: string, newNotebookId: string | null) {
const session = await auth()
@@ -198,7 +168,7 @@ export async function reconcileLabelsAfterNoteMove(noteId: string, newNotebookId
/* ignore */
}
}
await syncLabels(session.user.id, labels, newNotebookId)
await syncNoteLabels(noteId, labels, newNotebookId, session.user.id)
}
// Get all notes (non-archived by default)
@@ -441,7 +411,7 @@ export async function createNote(data: {
color: data.color || 'default',
type: data.type || 'text',
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
labels: data.labels && data.labels.length > 0 ? JSON.stringify(data.labels) : null,
labels: null, // set by syncNoteLabels below
images: data.images ? JSON.stringify(data.images) : null,
links: data.links ? JSON.stringify(data.links) : null,
isArchived: data.isArchived || false,
@@ -454,9 +424,9 @@ export async function createNote(data: {
}
})
// Sync user-provided labels immediately (étiquettes rattachées au carnet de la note)
// Sync labels (JSON + labelRelations + Label rows) in one call
if (data.labels && data.labels.length > 0) {
await syncLabels(session.user.id, data.labels, data.notebookId ?? null)
await syncNoteLabels(note.id, data.labels, data.notebookId ?? null, session.user.id)
}
if (!data.skipRevalidation) {
@@ -528,11 +498,22 @@ export async function createNote(data: {
.map(s => s.label)
if (appliedLabels.length > 0) {
await prisma.note.update({
// Merge with existing labels
const existing = await prisma.note.findUnique({
where: { id: noteId },
data: { labels: JSON.stringify(appliedLabels) }
select: { labels: true },
})
await syncLabels(userId, appliedLabels, notebookId ?? null)
let existingNames: string[] = []
if (existing?.labels) {
try {
const parsed = existing.labels as unknown
existingNames = Array.isArray(parsed)
? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0)
: []
} catch { existingNames = [] }
}
const merged = [...new Set([...existingNames, ...appliedLabels])]
await syncNoteLabels(noteId, merged, notebookId ?? null, userId)
if (!data.skipRevalidation) {
revalidatePath('/')
}
@@ -616,7 +597,8 @@ export async function updateNote(id: string, data: {
}
if ('checkItems' in data) updateData.checkItems = data.checkItems ? JSON.stringify(data.checkItems) : null
if ('labels' in data) updateData.labels = data.labels ? JSON.stringify(data.labels) : null
// labels handled by syncNoteLabels below
delete updateData.labels
if ('images' in data) updateData.images = data.images ? JSON.stringify(data.images) : null
if ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null
if ('notebookId' in data) updateData.notebookId = data.notebookId
@@ -637,14 +619,14 @@ export async function updateNote(id: string, data: {
data: updateData
})
// Sync Label rows (carnet + noms) quand les étiquettes changent ou que la note change de carnet
// Sync labels (JSON + labelRelations + Label rows)
const notebookMoved =
data.notebookId !== undefined && data.notebookId !== oldNotebookId
if (data.labels !== undefined || notebookMoved) {
const labelsToSync = data.labels !== undefined ? (data.labels || []) : oldLabels
const effectiveNotebookId =
data.notebookId !== undefined ? data.notebookId : oldNotebookId
await syncLabels(session.user.id, labelsToSync, effectiveNotebookId ?? null)
await syncNoteLabels(id, labelsToSync, effectiveNotebookId ?? null, session.user.id)
}
// Only revalidate for STRUCTURAL changes that affect the page layout/lists

View File

@@ -55,10 +55,12 @@ export class ContextualAutoTagService {
// CASE 1: Notebook has existing labels → suggest from them (IA2)
if (notebook.labels.length > 0) {
return await this.suggestFromExistingLabels(noteContent, notebook, language)
const existing = await this.suggestFromExistingLabels(noteContent, notebook, language)
if (existing.length > 0) return existing
// Fallback: no existing label matched → suggest new ones too
}
// CASE 2: Notebook has NO labels → suggest NEW labels to create
// CASE 2: No labels in notebook (or none matched) → suggest NEW labels
return await this.suggestNewLabels(noteContent, notebook, language)
}
@@ -126,20 +128,23 @@ export class ContextualAutoTagService {
return []
}
// Filter and map suggestions
// Filter and map suggestions (case-insensitive)
const lowerAvailable = availableLabels.map((l: string) => l.toLowerCase())
const suggestions = suggestionsArray
.filter((s: any) => {
// Must be in available labels
return availableLabels.includes(s.label) && s.confidence > 0.6
return s.label && lowerAvailable.includes(s.label.toLowerCase()) && (s.confidence || 0) > 0.3
})
.map((s: any) => ({
label: s.label,
.map((s: any) => {
const originalLabel = availableLabels.find((l: string) => l.toLowerCase() === s.label.toLowerCase()) || s.label
return {
label: originalLabel,
confidence: Math.round(s.confidence * 100),
reasoning: s.reasoning || '',
isNewLabel: false,
}))
}
})
.sort((a: any, b: any) => b.confidence - a.confidence)
.slice(0, 3) // Max 3 suggestions
.slice(0, 5)
return suggestions as LabelSuggestion[]
} catch (error) {
@@ -213,7 +218,7 @@ export class ContextualAutoTagService {
// Filter and map suggestions
const suggestions = suggestionsArray
.filter((s: any) => {
return s.label && s.label.length > 0 && s.confidence > 0.6
return s.label && s.label.length > 0 && (s.confidence || 0) > 0.3
})
.map((s: any) => ({
label: s.label,
@@ -222,7 +227,7 @@ export class ContextualAutoTagService {
isNewLabel: true, // Mark as new label suggestion
}))
.sort((a: any, b: any) => b.confidence - a.confidence)
.slice(0, 3) // Max 3 suggestions
.slice(0, 5)
return suggestions as LabelSuggestion[]
} catch (error) {