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:
Sepehr Ramezani
2026-04-20 22:48:06 +02:00
parent 402e88b788
commit e4d4e23dc7
3981 changed files with 407 additions and 530622 deletions

View 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()