fix: label system overhaul - sync dual storage, fix suggestions
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 53s
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:
@@ -99,85 +99,55 @@ function collectLabelNamesFromNote(note: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sync Label rows with Note.labels.
|
* Upsert Label rows and return their IDs.
|
||||||
* Optimisé: createMany (bulk) + delete en parallèle — uniquement 3-4 requêtes au lieu de N+2.
|
* 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 {
|
try {
|
||||||
const nbScope = notebookId ?? null
|
const nbScope = notebookId ?? null
|
||||||
|
|
||||||
// 1. Bulk-upsert les nouveaux labels via upsert en transaction
|
|
||||||
if (noteLabels.length > 0) {
|
if (noteLabels.length > 0) {
|
||||||
const trimmedNames = [...new Set(
|
const trimmedNames = [...new Set(
|
||||||
noteLabels.map(name => name?.trim()).filter((n): n is string => Boolean(n))
|
noteLabels.map(name => name?.trim()).filter((n): n is string => Boolean(n))
|
||||||
)]
|
)]
|
||||||
|
for (const name of trimmedNames) {
|
||||||
if (trimmedNames.length > 0) {
|
const existing = await prisma.label.findFirst({
|
||||||
await prisma.$transaction(
|
where: { userId, name, notebookId: nbScope },
|
||||||
trimmedNames.map(name =>
|
})
|
||||||
prisma.label.upsert({
|
if (!existing) {
|
||||||
where: { notebookId_name: { notebookId: nbScope ?? '', name } as any },
|
await prisma.label.create({
|
||||||
update: {},
|
data: { userId, name, color: getHashColor(name), notebookId: nbScope },
|
||||||
create: {
|
})
|
||||||
userId,
|
}
|
||||||
name,
|
|
||||||
color: getHashColor(name),
|
|
||||||
notebookId: nbScope,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Récupérer les labels utilisés par toutes les notes de l'utilisateur
|
if (noteLabels.length === 0) return []
|
||||||
const [allNotes, allLabels] = await Promise.all([
|
const uniqueNames = [...new Set(noteLabels.map(n => n.trim().toLowerCase()).filter(Boolean))]
|
||||||
prisma.note.findMany({
|
return prisma.label.findMany({
|
||||||
where: { userId },
|
where: { userId, notebookId: nbScope, name: { in: uniqueNames } },
|
||||||
select: {
|
select: { id: true, name: true },
|
||||||
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 } }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fatal error in syncLabels:', 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 */
|
/** Après déplacement via API : rattacher les étiquettes de la note au bon carnet */
|
||||||
export async function reconcileLabelsAfterNoteMove(noteId: string, newNotebookId: string | null) {
|
export async function reconcileLabelsAfterNoteMove(noteId: string, newNotebookId: string | null) {
|
||||||
const session = await auth()
|
const session = await auth()
|
||||||
@@ -198,7 +168,7 @@ export async function reconcileLabelsAfterNoteMove(noteId: string, newNotebookId
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await syncLabels(session.user.id, labels, newNotebookId)
|
await syncNoteLabels(noteId, labels, newNotebookId, session.user.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all notes (non-archived by default)
|
// Get all notes (non-archived by default)
|
||||||
@@ -441,7 +411,7 @@ export async function createNote(data: {
|
|||||||
color: data.color || 'default',
|
color: data.color || 'default',
|
||||||
type: data.type || 'text',
|
type: data.type || 'text',
|
||||||
checkItems: data.checkItems ? JSON.stringify(data.checkItems) : null,
|
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,
|
images: data.images ? JSON.stringify(data.images) : null,
|
||||||
links: data.links ? JSON.stringify(data.links) : null,
|
links: data.links ? JSON.stringify(data.links) : null,
|
||||||
isArchived: data.isArchived || false,
|
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) {
|
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) {
|
if (!data.skipRevalidation) {
|
||||||
@@ -528,11 +498,22 @@ export async function createNote(data: {
|
|||||||
.map(s => s.label)
|
.map(s => s.label)
|
||||||
|
|
||||||
if (appliedLabels.length > 0) {
|
if (appliedLabels.length > 0) {
|
||||||
await prisma.note.update({
|
// Merge with existing labels
|
||||||
|
const existing = await prisma.note.findUnique({
|
||||||
where: { id: noteId },
|
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) {
|
if (!data.skipRevalidation) {
|
||||||
revalidatePath('/')
|
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 ('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 ('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 ('links' in data) updateData.links = data.links ? JSON.stringify(data.links) : null
|
||||||
if ('notebookId' in data) updateData.notebookId = data.notebookId
|
if ('notebookId' in data) updateData.notebookId = data.notebookId
|
||||||
@@ -637,14 +619,14 @@ export async function updateNote(id: string, data: {
|
|||||||
data: updateData
|
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 =
|
const notebookMoved =
|
||||||
data.notebookId !== undefined && data.notebookId !== oldNotebookId
|
data.notebookId !== undefined && data.notebookId !== oldNotebookId
|
||||||
if (data.labels !== undefined || notebookMoved) {
|
if (data.labels !== undefined || notebookMoved) {
|
||||||
const labelsToSync = data.labels !== undefined ? (data.labels || []) : oldLabels
|
const labelsToSync = data.labels !== undefined ? (data.labels || []) : oldLabels
|
||||||
const effectiveNotebookId =
|
const effectiveNotebookId =
|
||||||
data.notebookId !== undefined ? data.notebookId : oldNotebookId
|
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
|
// Only revalidate for STRUCTURAL changes that affect the page layout/lists
|
||||||
|
|||||||
@@ -55,10 +55,12 @@ export class ContextualAutoTagService {
|
|||||||
|
|
||||||
// CASE 1: Notebook has existing labels → suggest from them (IA2)
|
// CASE 1: Notebook has existing labels → suggest from them (IA2)
|
||||||
if (notebook.labels.length > 0) {
|
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)
|
return await this.suggestNewLabels(noteContent, notebook, language)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,20 +128,23 @@ export class ContextualAutoTagService {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter and map suggestions
|
// Filter and map suggestions (case-insensitive)
|
||||||
|
const lowerAvailable = availableLabels.map((l: string) => l.toLowerCase())
|
||||||
const suggestions = suggestionsArray
|
const suggestions = suggestionsArray
|
||||||
.filter((s: any) => {
|
.filter((s: any) => {
|
||||||
// Must be in available labels
|
return s.label && lowerAvailable.includes(s.label.toLowerCase()) && (s.confidence || 0) > 0.3
|
||||||
return availableLabels.includes(s.label) && s.confidence > 0.6
|
|
||||||
})
|
})
|
||||||
.map((s: any) => ({
|
.map((s: any) => {
|
||||||
label: s.label,
|
const originalLabel = availableLabels.find((l: string) => l.toLowerCase() === s.label.toLowerCase()) || s.label
|
||||||
|
return {
|
||||||
|
label: originalLabel,
|
||||||
confidence: Math.round(s.confidence * 100),
|
confidence: Math.round(s.confidence * 100),
|
||||||
reasoning: s.reasoning || '',
|
reasoning: s.reasoning || '',
|
||||||
isNewLabel: false,
|
isNewLabel: false,
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
.sort((a: any, b: any) => b.confidence - a.confidence)
|
.sort((a: any, b: any) => b.confidence - a.confidence)
|
||||||
.slice(0, 3) // Max 3 suggestions
|
.slice(0, 5)
|
||||||
|
|
||||||
return suggestions as LabelSuggestion[]
|
return suggestions as LabelSuggestion[]
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -213,7 +218,7 @@ export class ContextualAutoTagService {
|
|||||||
// Filter and map suggestions
|
// Filter and map suggestions
|
||||||
const suggestions = suggestionsArray
|
const suggestions = suggestionsArray
|
||||||
.filter((s: any) => {
|
.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) => ({
|
.map((s: any) => ({
|
||||||
label: s.label,
|
label: s.label,
|
||||||
@@ -222,7 +227,7 @@ export class ContextualAutoTagService {
|
|||||||
isNewLabel: true, // Mark as new label suggestion
|
isNewLabel: true, // Mark as new label suggestion
|
||||||
}))
|
}))
|
||||||
.sort((a: any, b: any) => b.confidence - a.confidence)
|
.sort((a: any, b: any) => b.confidence - a.confidence)
|
||||||
.slice(0, 3) // Max 3 suggestions
|
.slice(0, 5)
|
||||||
|
|
||||||
return suggestions as LabelSuggestion[]
|
return suggestions as LabelSuggestion[]
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user