## Translation Files - Add 11 new language files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl) - Add 100+ missing translation keys across all 15 languages - New sections: notebook, pagination, ai.batchOrganization, ai.autoLabels - Update nav section with workspace, quickAccess, myLibrary keys ## Component Updates - Update 15+ components to use translation keys instead of hardcoded text - Components: notebook dialogs, sidebar, header, note-input, ghost-tags, etc. - Replace 80+ hardcoded English/French strings with t() calls - Ensure consistent UI across all supported languages ## Code Quality - Remove 77+ console.log statements from codebase - Clean up API routes, components, hooks, and services - Keep only essential error handling (no debugging logs) ## UI/UX Improvements - Update Keep logo to yellow post-it style (from-yellow-400 to-amber-500) - Change selection colors to #FEF3C6 (notebooks) and #EFB162 (nav items) - Make "+" button permanently visible in notebooks section - Fix grammar and syntax errors in multiple components ## Bug Fixes - Fix JSON syntax errors in it.json, nl.json, pl.json, zh.json - Fix syntax errors in notebook-suggestion-toast.tsx - Fix syntax errors in use-auto-tagging.ts - Fix syntax errors in paragraph-refactor.service.ts - Fix duplicate "fusion" section in nl.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Ou une version plus courte si vous préférez : feat(i18n): Add 15 languages, remove logs, update UI components - Create 11 new translation files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl) - Add 100+ translation keys: notebook, pagination, AI features - Update 15+ components to use translations (80+ strings) - Remove 77+ console.log statements from codebase - Fix JSON syntax errors in 4 translation files - Fix component syntax errors (toast, hooks, services) - Update logo to yellow post-it style - Change selection colors (#FEF3C6, #EFB162) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
294 lines
7.4 KiB
TypeScript
294 lines
7.4 KiB
TypeScript
import { prisma } from '@/lib/prisma'
|
|
import { getAIProvider } from '@/lib/ai/factory'
|
|
|
|
export interface SuggestedLabel {
|
|
name: string
|
|
count: number
|
|
confidence: number
|
|
noteIds: string[]
|
|
}
|
|
|
|
export interface AutoLabelSuggestion {
|
|
notebookId: string
|
|
notebookName: string
|
|
notebookIcon: string | null
|
|
suggestedLabels: SuggestedLabel[]
|
|
totalNotes: number
|
|
}
|
|
|
|
/**
|
|
* Service for automatically suggesting new labels based on recurring themes
|
|
* (Story 5.4 - IA4)
|
|
*/
|
|
export class AutoLabelCreationService {
|
|
/**
|
|
* Analyze a notebook and suggest new labels based on recurring themes
|
|
* @param notebookId - Notebook ID to analyze
|
|
* @param userId - User ID (for authorization)
|
|
* @returns Suggested labels or null if not enough notes/no patterns found
|
|
*/
|
|
async suggestLabels(notebookId: string, userId: string): Promise<AutoLabelSuggestion | null> {
|
|
// 1. Get notebook with existing labels
|
|
const notebook = await prisma.notebook.findFirst({
|
|
where: {
|
|
id: notebookId,
|
|
userId,
|
|
},
|
|
include: {
|
|
labels: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
},
|
|
},
|
|
_count: {
|
|
select: { notes: true },
|
|
},
|
|
},
|
|
})
|
|
|
|
if (!notebook) {
|
|
throw new Error('Notebook not found')
|
|
}
|
|
|
|
// Only trigger if notebook has 15+ notes (PRD requirement)
|
|
if (notebook._count.notes < 15) {
|
|
return null
|
|
}
|
|
|
|
// Get all notes in this notebook
|
|
const notes = await prisma.note.findMany({
|
|
where: {
|
|
notebookId,
|
|
userId,
|
|
},
|
|
select: {
|
|
id: true,
|
|
title: true,
|
|
content: true,
|
|
labelRelations: {
|
|
select: {
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: {
|
|
updatedAt: 'desc',
|
|
},
|
|
take: 100, // Limit to 100 most recent notes
|
|
})
|
|
|
|
if (notes.length === 0) {
|
|
return null
|
|
}
|
|
|
|
// 2. Use AI to detect recurring themes
|
|
const suggestions = await this.detectRecurringThemes(notes, notebook)
|
|
|
|
return suggestions
|
|
}
|
|
|
|
/**
|
|
* Use AI to detect recurring themes and suggest labels
|
|
*/
|
|
private async detectRecurringThemes(
|
|
notes: any[],
|
|
notebook: any
|
|
): Promise<AutoLabelSuggestion | null> {
|
|
const existingLabelNames = new Set<string>(
|
|
notebook.labels.map((l: any) => l.name.toLowerCase())
|
|
)
|
|
|
|
const prompt = this.buildPrompt(notes, existingLabelNames)
|
|
|
|
try {
|
|
const provider = getAIProvider()
|
|
const response = await provider.generateText(prompt)
|
|
|
|
// Parse AI response
|
|
const suggestions = this.parseAIResponse(response, notes)
|
|
|
|
if (!suggestions || suggestions.suggestedLabels.length === 0) {
|
|
return null
|
|
}
|
|
|
|
return {
|
|
notebookId: notebook.id,
|
|
notebookName: notebook.name,
|
|
notebookIcon: notebook.icon,
|
|
suggestedLabels: suggestions.suggestedLabels,
|
|
totalNotes: notebook._count.notes,
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to detect recurring themes:', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build prompt for AI (always in French - interface language)
|
|
*/
|
|
private buildPrompt(notes: any[], existingLabelNames: Set<string>): string {
|
|
const notesSummary = notes
|
|
.map((note, index) => {
|
|
const title = note.title || 'Sans titre'
|
|
const content = note.content.substring(0, 150)
|
|
return `[${index}] "${title}": ${content}`
|
|
})
|
|
.join('\n')
|
|
|
|
const existingLabels = Array.from(existingLabelNames).join(', ')
|
|
|
|
return `
|
|
Tu es un assistant qui détecte les thèmes récurrents dans des notes pour suggérer de nouvelles étiquettes.
|
|
|
|
CARNET ANALYSÉ :
|
|
${notes.length} notes
|
|
|
|
ÉTIQUETTES EXISTANTES (ne pas suggérer celles-ci) :
|
|
${existingLabels || 'Aucune'}
|
|
|
|
NOTES DU CARNET :
|
|
${notesSummary}
|
|
|
|
TÂCHE :
|
|
Analyse les notes et détecte les thèmes récurrents (mots-clés, sujets, lieux, personnes).
|
|
Un thème doit apparaître dans au moins 5 notes différentes pour être suggéré.
|
|
|
|
FORMAT DE RÉPONSE (JSON) :
|
|
{
|
|
"labels": [
|
|
{
|
|
"nom": "nom_du_label",
|
|
"note_indices": [0, 5, 12, 23, 45],
|
|
"confiance": 0.85
|
|
}
|
|
]
|
|
}
|
|
|
|
RÈGLES :
|
|
- Le nom du label doit être court (1-2 mots max)
|
|
- Un thème doit apparaître dans 5+ notes pour être suggéré
|
|
- La confiance doit être > 0.60
|
|
- Ne pas suggérer des étiquettes qui existent déjà
|
|
- Priorise les lieux, personnes, catégories claires
|
|
- Maximum 5 suggestions
|
|
|
|
Exemples de bonnes étiquettes :
|
|
- "tokyo", "kyoto", "osaka" (lieux)
|
|
- "hôtels", "restos", "vols" (catégories)
|
|
- "marie", "jean", "équipe" (personnes)
|
|
|
|
Ta réponse (JSON seulement) :
|
|
`.trim()
|
|
}
|
|
|
|
/**
|
|
* Parse AI response into suggested labels
|
|
*/
|
|
private parseAIResponse(response: string, notes: any[]): { suggestedLabels: SuggestedLabel[] } | null {
|
|
try {
|
|
const jsonMatch = response.match(/\{[\s\S]*\}/)
|
|
if (!jsonMatch) {
|
|
throw new Error('No JSON found in response')
|
|
}
|
|
|
|
const aiData = JSON.parse(jsonMatch[0])
|
|
|
|
const suggestedLabels: SuggestedLabel[] = (aiData.labels || [])
|
|
.map((label: any) => {
|
|
// Filter by confidence threshold
|
|
if (label.confiance <= 0.60) return null
|
|
|
|
// Get note IDs from indices
|
|
const noteIds = label.note_indices
|
|
.map((idx: number) => notes[idx]?.id)
|
|
.filter(Boolean)
|
|
|
|
// Must have at least 5 notes
|
|
if (noteIds.length < 5) return null
|
|
|
|
return {
|
|
name: label.nom,
|
|
count: noteIds.length,
|
|
confidence: label.confiance,
|
|
noteIds,
|
|
}
|
|
})
|
|
.filter(Boolean)
|
|
|
|
if (suggestedLabels.length === 0) {
|
|
return null
|
|
}
|
|
|
|
// Sort by count (descending) and confidence
|
|
suggestedLabels.sort((a, b) => {
|
|
if (b.count !== a.count) {
|
|
return b.count - a.count // More notes first
|
|
}
|
|
return b.confidence - a.confidence // Then higher confidence
|
|
})
|
|
|
|
// Limit to top 5
|
|
return {
|
|
suggestedLabels: suggestedLabels.slice(0, 5),
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to parse AI response:', error)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create suggested labels and assign them to notes
|
|
* @param notebookId - Notebook ID
|
|
* @param userId - User ID
|
|
* @param suggestions - Suggested labels to create
|
|
* @param selectedLabels - Labels user selected to create
|
|
* @returns Number of labels created
|
|
*/
|
|
async createLabels(
|
|
notebookId: string,
|
|
userId: string,
|
|
suggestions: AutoLabelSuggestion,
|
|
selectedLabels: string[]
|
|
): Promise<number> {
|
|
let createdCount = 0
|
|
|
|
for (const suggestedLabel of suggestions.suggestedLabels) {
|
|
if (!selectedLabels.includes(suggestedLabel.name)) continue
|
|
|
|
// Create the label
|
|
const label = await prisma.label.create({
|
|
data: {
|
|
name: suggestedLabel.name,
|
|
color: 'gray', // Default color, user can change later
|
|
notebookId,
|
|
userId,
|
|
},
|
|
})
|
|
|
|
// Assign label to all suggested notes (updateMany doesn't support relations)
|
|
for (const noteId of suggestedLabel.noteIds) {
|
|
await prisma.note.update({
|
|
where: { id: noteId },
|
|
data: {
|
|
labelRelations: {
|
|
connect: {
|
|
id: label.id,
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
createdCount++
|
|
}
|
|
|
|
return createdCount
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const autoLabelCreationService = new AutoLabelCreationService()
|