chore: clean up repo for public release
- Remove BMAD framework, IDE configs, dev screenshots, test files, internal docs, and backup files - Rename keep-notes/ to memento-note/ - Update all references from keep-notes to memento-note - Add Apache 2.0 license with Commons Clause (non-commercial restriction) - Add clean .gitignore and .env.docker.example
This commit is contained in:
491
memento-note/lib/ai/services/auto-label-creation.service.ts
Normal file
491
memento-note/lib/ai/services/auto-label-creation.service.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
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, language: string = 'en'): 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,
|
||||
trashedAt: null,
|
||||
},
|
||||
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, language)
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
/**
|
||||
* Use AI to detect recurring themes and suggest labels
|
||||
*/
|
||||
private async detectRecurringThemes(
|
||||
notes: any[],
|
||||
notebook: any,
|
||||
language: string
|
||||
): Promise<AutoLabelSuggestion | null> {
|
||||
const existingLabelNames = new Set<string>(
|
||||
notebook.labels.map((l: any) => l.name.toLowerCase())
|
||||
)
|
||||
|
||||
const prompt = this.buildPrompt(notes, existingLabelNames, language)
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const provider = getAIProvider(config)
|
||||
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 (localized)
|
||||
*/
|
||||
private buildPrompt(notes: any[], existingLabelNames: Set<string>, language: string = 'en'): 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(', ')
|
||||
|
||||
const instructions: Record<string, string> = {
|
||||
fr: `
|
||||
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(),
|
||||
en: `
|
||||
You are an assistant that detects recurring themes in notes to suggest new labels.
|
||||
|
||||
ANALYZED NOTEBOOK:
|
||||
${notes.length} notes
|
||||
|
||||
EXISTING LABELS (do not suggest these):
|
||||
${existingLabels || 'None'}
|
||||
|
||||
NOTEBOOK NOTES:
|
||||
${notesSummary}
|
||||
|
||||
TASK:
|
||||
Analyze the notes and detect recurring themes (keywords, subjects, places, people).
|
||||
A theme must appear in at least 5 different notes to be suggested.
|
||||
|
||||
RESPONSE FORMAT (JSON):
|
||||
{
|
||||
"labels": [
|
||||
{
|
||||
"nom": "label_name",
|
||||
"note_indices": [0, 5, 12, 23, 45],
|
||||
"confiance": 0.85
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
RULES:
|
||||
- Label name must be short (max 1-2 words)
|
||||
- A theme must appear in 5+ notes to be suggested
|
||||
- Confidence must be > 0.60
|
||||
- Do not suggest labels that already exist
|
||||
- Prioritize places, people, clear categories
|
||||
- Maximum 5 suggestions
|
||||
|
||||
Examples of good labels:
|
||||
- "tokyo", "kyoto", "osaka" (places)
|
||||
- "hotels", "restaurants", "flights" (categories)
|
||||
- "mary", "john", "team" (people)
|
||||
|
||||
Your response (JSON only):
|
||||
`.trim(),
|
||||
fa: `
|
||||
شما یک دستیار هستید که تمهای تکرارشونده در یادداشتها را برای پیشنهاد برچسبهای جدید شناسایی میکنید.
|
||||
|
||||
دفترچه تحلیل شده:
|
||||
${notes.length} یادداشت
|
||||
|
||||
برچسبهای موجود (اینها را پیشنهاد ندهید):
|
||||
${existingLabels || 'هیچ'}
|
||||
|
||||
یادداشتهای دفترچه:
|
||||
${notesSummary}
|
||||
|
||||
وظیفه:
|
||||
یادداشتها را تحلیل کنید و تمهای تکرارشونده (کلمات کلیدی، موضوعات، مکانها، افراد) را شناسایی کنید.
|
||||
یک تم باید حداقل در ۵ یادداشت مختلف ظاهر شود تا پیشنهاد داده شود.
|
||||
|
||||
فرمت پاسخ (JSON):
|
||||
{
|
||||
"labels": [
|
||||
{
|
||||
"nom": "نام_برچسب",
|
||||
"note_indices": [0, 5, 12, 23, 45],
|
||||
"confiance": 0.85
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
قوانین:
|
||||
- نام برچسب باید کوتاه باشد (حداکثر ۱-۲ کلمه)
|
||||
- یک تم باید در ۵+ یادداشت ظاهر شود تا پیشنهاد داده شود
|
||||
- اطمینان باید > 0.60 باشد
|
||||
- برچسبهایی که قبلاً وجود دارند را پیشنهاد ندهید
|
||||
- اولویت با مکانها، افراد، دستهبندیهای واضح است
|
||||
- حداکثر ۵ پیشنهاد
|
||||
|
||||
مثالهای برچسب خوب:
|
||||
- "توکیو"، "کیوتو"، "اوزاکا" (مکانها)
|
||||
- "هتلها"، "رستورانها"، "پروازها" (دستهبندیها)
|
||||
- "مریم"، "علی"، "تیم" (افراد)
|
||||
|
||||
پاسخ شما (فقط JSON):
|
||||
`.trim(),
|
||||
es: `
|
||||
Eres un asistente que detecta temas recurrentes en notas para sugerir nuevas etiquetas.
|
||||
|
||||
CUADERNO ANALIZADO:
|
||||
${notes.length} notas
|
||||
|
||||
ETIQUETAS EXISTENTES (no sugerir estas):
|
||||
${existingLabels || 'Ninguna'}
|
||||
|
||||
NOTAS DEL CUADERNO:
|
||||
${notesSummary}
|
||||
|
||||
TAREA:
|
||||
Analiza las notas y detecta temas recurrentes (palabras clave, temas, lugares, personas).
|
||||
Un tema debe aparecer en al menos 5 notas diferentes para ser sugerido.
|
||||
|
||||
FORMATO DE RESPUESTA (JSON):
|
||||
{
|
||||
"labels": [
|
||||
{
|
||||
"nom": "nombre_etiqueta",
|
||||
"note_indices": [0, 5, 12, 23, 45],
|
||||
"confiance": 0.85
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
REGLAS:
|
||||
- El nombre de la etiqueta debe ser corto (máx 1-2 palabras)
|
||||
- Un tema debe aparecer en 5+ notas para ser sugerido
|
||||
- La confianza debe ser > 0.60
|
||||
- No sugieras etiquetas que ya existen
|
||||
- Prioriza lugares, personas, categorías claras
|
||||
- Máximo 5 sugerencias
|
||||
|
||||
Ejemplos de buenas etiquetas:
|
||||
- "tokio", "kyoto", "osaka" (lugares)
|
||||
- "hoteles", "restaurantes", "vuelos" (categorías)
|
||||
- "maría", "juan", "equipo" (personas)
|
||||
|
||||
Tu respuesta (solo JSON):
|
||||
`.trim(),
|
||||
de: `
|
||||
Du bist ein Assistent, der wiederkehrende Themen in Notizen erkennt, um neue Labels vorzuschlagen.
|
||||
|
||||
ANALYSIERTES NOTIZBUCH:
|
||||
${notes.length} Notizen
|
||||
|
||||
VORHANDENE LABELS (schlage diese nicht vor):
|
||||
${existingLabels || 'Keine'}
|
||||
|
||||
NOTIZBUCH-NOTIZEN:
|
||||
${notesSummary}
|
||||
|
||||
AUFGABE:
|
||||
Analysiere die Notizen und erkenne wiederkehrende Themen (Schlüsselwörter, Themen, Orte, Personen).
|
||||
Ein Thema muss in mindestens 5 verschiedenen Notizen erscheinen, um vorgeschlagen zu werden.
|
||||
|
||||
ANTWORTFORMAT (JSON):
|
||||
{
|
||||
"labels": [
|
||||
{
|
||||
"nom": "label_name",
|
||||
"note_indices": [0, 5, 12, 23, 45],
|
||||
"confiance": 0.85
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
REGELN:
|
||||
- Der Labelname muss kurz sein (max 1-2 Wörter)
|
||||
- Ein Thema muss in 5+ Notizen erscheinen, um vorgeschlagen zu werden
|
||||
- Konfidenz muss > 0.60 sein
|
||||
- Schlage keine Labels vor, die bereits existieren
|
||||
- Priorisiere Orte, Personen, klare Kategorien
|
||||
- Maximal 5 Vorschläge
|
||||
|
||||
Beispiele für gute Labels:
|
||||
- "tokio", "kyoto", "osaka" (Orte)
|
||||
- "hotels", "restaurants", "flüge" (Kategorien)
|
||||
- "maria", "johannes", "team" (Personen)
|
||||
|
||||
Deine Antwort (nur JSON):
|
||||
`.trim()
|
||||
}
|
||||
|
||||
return instructions[language] || instructions['en'] || instructions['fr']
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 to notes: UI reads `Note.labels` (JSON string[]); relations must stay in sync
|
||||
for (const noteId of suggestedLabel.noteIds) {
|
||||
const note = await prisma.note.findFirst({
|
||||
where: { id: noteId, userId, notebookId },
|
||||
select: { labels: true },
|
||||
})
|
||||
if (!note) continue
|
||||
|
||||
let names: string[] = []
|
||||
if (note.labels) {
|
||||
try {
|
||||
const parsed = note.labels as unknown
|
||||
names = Array.isArray(parsed)
|
||||
? parsed.filter((n): n is string => typeof n === 'string' && n.trim().length > 0)
|
||||
: []
|
||||
} catch {
|
||||
names = []
|
||||
}
|
||||
}
|
||||
|
||||
const trimmed = suggestedLabel.name.trim()
|
||||
if (!names.some((n) => n.toLowerCase() === trimmed.toLowerCase())) {
|
||||
names = [...names, suggestedLabel.name]
|
||||
}
|
||||
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: {
|
||||
labels: JSON.stringify(names),
|
||||
labelRelations: {
|
||||
connect: { id: label.id },
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
createdCount++
|
||||
}
|
||||
|
||||
return createdCount
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const autoLabelCreationService = new AutoLabelCreationService()
|
||||
Reference in New Issue
Block a user