feat: Complete internationalization and code cleanup
## 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>
This commit is contained in:
@@ -20,7 +20,6 @@ function createOpenAIProvider(config: Record<string, string>, modelName: string,
|
||||
const apiKey = config?.OPENAI_API_KEY || process.env.OPENAI_API_KEY || '';
|
||||
|
||||
if (!apiKey) {
|
||||
console.warn('OPENAI_API_KEY non configurée.');
|
||||
}
|
||||
|
||||
return new OpenAIProvider(apiKey, modelName, embeddingModelName);
|
||||
@@ -31,11 +30,9 @@ function createCustomOpenAIProvider(config: Record<string, string>, modelName: s
|
||||
const baseUrl = config?.CUSTOM_OPENAI_BASE_URL || process.env.CUSTOM_OPENAI_BASE_URL || '';
|
||||
|
||||
if (!apiKey) {
|
||||
console.warn('CUSTOM_OPENAI_API_KEY non configurée.');
|
||||
}
|
||||
|
||||
if (!baseUrl) {
|
||||
console.warn('CUSTOM_OPENAI_BASE_URL non configurée.');
|
||||
}
|
||||
|
||||
return new CustomOpenAIProvider(apiKey, baseUrl, modelName, embeddingModelName);
|
||||
@@ -50,7 +47,6 @@ function getProviderInstance(providerType: ProviderType, config: Record<string,
|
||||
case 'custom':
|
||||
return createCustomOpenAIProvider(config, modelName, embeddingModelName);
|
||||
default:
|
||||
console.warn(`Provider AI inconnu: ${providerType}, utilisation de Ollama par défaut`);
|
||||
return createOllamaProvider(config, modelName, embeddingModelName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { generateObject, embed } from 'ai';
|
||||
import { generateObject, generateText, embed } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { AIProvider, TagSuggestion } from '../types';
|
||||
import { AIProvider, TagSuggestion, TitleSuggestion } from '../types';
|
||||
|
||||
export class CustomOpenAIProvider implements AIProvider {
|
||||
private model: any;
|
||||
@@ -56,4 +56,38 @@ export class CustomOpenAIProvider implements AIProvider {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
|
||||
try {
|
||||
const { object } = await generateObject({
|
||||
model: this.model,
|
||||
schema: z.object({
|
||||
titles: z.array(z.object({
|
||||
title: z.string().describe('Le titre suggéré'),
|
||||
confidence: z.number().min(0).max(1).describe('Le niveau de confiance entre 0 et 1')
|
||||
}))
|
||||
}),
|
||||
prompt: prompt,
|
||||
});
|
||||
|
||||
return object.titles;
|
||||
} catch (e) {
|
||||
console.error('Erreur génération titres Custom OpenAI:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async generateText(prompt: string): Promise<string> {
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: this.model,
|
||||
prompt: prompt,
|
||||
});
|
||||
|
||||
return text.trim();
|
||||
} catch (e) {
|
||||
console.error('Erreur génération texte Custom OpenAI:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { generateObject, embed } from 'ai';
|
||||
import { generateObject, generateText, embed } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { AIProvider, TagSuggestion } from '../types';
|
||||
import { AIProvider, TagSuggestion, TitleSuggestion } from '../types';
|
||||
|
||||
export class DeepSeekProvider implements AIProvider {
|
||||
private model: any;
|
||||
@@ -51,4 +51,38 @@ export class DeepSeekProvider implements AIProvider {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
|
||||
try {
|
||||
const { object } = await generateObject({
|
||||
model: this.model,
|
||||
schema: z.object({
|
||||
titles: z.array(z.object({
|
||||
title: z.string().describe('Le titre suggéré'),
|
||||
confidence: z.number().min(0).max(1).describe('Le niveau de confiance entre 0 et 1')
|
||||
}))
|
||||
}),
|
||||
prompt: prompt,
|
||||
});
|
||||
|
||||
return object.titles;
|
||||
} catch (e) {
|
||||
console.error('Erreur génération titres DeepSeek:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async generateText(prompt: string): Promise<string> {
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: this.model,
|
||||
prompt: prompt,
|
||||
});
|
||||
|
||||
return text.trim();
|
||||
} catch (e) {
|
||||
console.error('Erreur génération texte DeepSeek:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AIProvider, TagSuggestion } from '../types';
|
||||
import { AIProvider, TagSuggestion, TitleSuggestion } from '../types';
|
||||
|
||||
export class OllamaProvider implements AIProvider {
|
||||
private baseUrl: string;
|
||||
@@ -77,4 +77,58 @@ export class OllamaProvider implements AIProvider {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: this.modelName,
|
||||
prompt: `${prompt}
|
||||
|
||||
Réponds UNIQUEMENT sous forme de tableau JSON : [{"title": "string", "confidence": number}]`,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
|
||||
|
||||
const data = await response.json();
|
||||
const text = data.response;
|
||||
|
||||
// Extraire le JSON de la réponse
|
||||
const jsonMatch = text.match(/\[\s*\{[\s\S]*\}\s*\]/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (e) {
|
||||
console.error('Erreur génération titres Ollama:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async generateText(prompt: string): Promise<string> {
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: this.modelName,
|
||||
prompt: prompt,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(`Ollama error: ${response.statusText}`);
|
||||
|
||||
const data = await response.json();
|
||||
return data.response.trim();
|
||||
} catch (e) {
|
||||
console.error('Erreur génération texte Ollama:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { generateObject, embed } from 'ai';
|
||||
import { generateObject, generateText, embed } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { AIProvider, TagSuggestion } from '../types';
|
||||
import { AIProvider, TagSuggestion, TitleSuggestion } from '../types';
|
||||
|
||||
export class OpenAIProvider implements AIProvider {
|
||||
private model: any;
|
||||
@@ -50,4 +50,38 @@ export class OpenAIProvider implements AIProvider {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
|
||||
try {
|
||||
const { object } = await generateObject({
|
||||
model: this.model,
|
||||
schema: z.object({
|
||||
titles: z.array(z.object({
|
||||
title: z.string().describe('Le titre suggéré'),
|
||||
confidence: z.number().min(0).max(1).describe('Le niveau de confiance entre 0 et 1')
|
||||
}))
|
||||
}),
|
||||
prompt: prompt,
|
||||
});
|
||||
|
||||
return object.titles;
|
||||
} catch (e) {
|
||||
console.error('Erreur génération titres OpenAI:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async generateText(prompt: string): Promise<string> {
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: this.model,
|
||||
prompt: prompt,
|
||||
});
|
||||
|
||||
return text.trim();
|
||||
} catch (e) {
|
||||
console.error('Erreur génération texte OpenAI:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { generateObject, embed } from 'ai';
|
||||
import { generateObject, generateText, embed } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { AIProvider, TagSuggestion } from '../types';
|
||||
import { AIProvider, TagSuggestion, TitleSuggestion } from '../types';
|
||||
|
||||
export class OpenRouterProvider implements AIProvider {
|
||||
private model: any;
|
||||
@@ -51,4 +51,38 @@ export class OpenRouterProvider implements AIProvider {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async generateTitles(prompt: string): Promise<TitleSuggestion[]> {
|
||||
try {
|
||||
const { object } = await generateObject({
|
||||
model: this.model,
|
||||
schema: z.object({
|
||||
titles: z.array(z.object({
|
||||
title: z.string().describe('Le titre suggéré'),
|
||||
confidence: z.number().min(0).max(1).describe('Le niveau de confiance entre 0 et 1')
|
||||
}))
|
||||
}),
|
||||
prompt: prompt,
|
||||
});
|
||||
|
||||
return object.titles;
|
||||
} catch (e) {
|
||||
console.error('Erreur génération titres OpenRouter:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async generateText(prompt: string): Promise<string> {
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: this.model,
|
||||
prompt: prompt,
|
||||
});
|
||||
|
||||
return text.trim();
|
||||
} catch (e) {
|
||||
console.error('Erreur génération texte OpenRouter:', e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
293
keep-notes/lib/ai/services/auto-label-creation.service.ts
Normal file
293
keep-notes/lib/ai/services/auto-label-creation.service.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
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()
|
||||
305
keep-notes/lib/ai/services/batch-organization.service.ts
Normal file
305
keep-notes/lib/ai/services/batch-organization.service.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
|
||||
export interface NoteForOrganization {
|
||||
id: string
|
||||
title: string | null
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface NotebookOrganization {
|
||||
notebookId: string
|
||||
notebookName: string
|
||||
notebookIcon: string | null
|
||||
notebookColor: string | null
|
||||
notes: Array<{
|
||||
noteId: string
|
||||
title: string | null
|
||||
content: string
|
||||
confidence: number
|
||||
reason: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface OrganizationPlan {
|
||||
notebooks: NotebookOrganization[]
|
||||
totalNotes: number
|
||||
unorganizedNotes: number // Notes that couldn't be categorized
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for batch organizing notes from "Notes générales" into notebooks
|
||||
* (Story 5.3 - IA3)
|
||||
*/
|
||||
export class BatchOrganizationService {
|
||||
/**
|
||||
* Analyze all notes in "Notes générales" and create an organization plan
|
||||
* @param userId - User ID
|
||||
* @returns Organization plan with notebook assignments
|
||||
*/
|
||||
async createOrganizationPlan(userId: string): Promise<OrganizationPlan> {
|
||||
// 1. Get all notes without notebook (Inbox/Notes générales)
|
||||
const notesWithoutNotebook = await prisma.note.findMany({
|
||||
where: {
|
||||
userId,
|
||||
notebookId: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc',
|
||||
},
|
||||
take: 50, // Limit to 50 notes for AI processing
|
||||
})
|
||||
|
||||
if (notesWithoutNotebook.length === 0) {
|
||||
return {
|
||||
notebooks: [],
|
||||
totalNotes: 0,
|
||||
unorganizedNotes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Get all user's notebooks
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
labels: true,
|
||||
_count: {
|
||||
select: { notes: true },
|
||||
},
|
||||
},
|
||||
orderBy: { order: 'asc' },
|
||||
})
|
||||
|
||||
if (notebooks.length === 0) {
|
||||
// No notebooks to organize into
|
||||
return {
|
||||
notebooks: [],
|
||||
totalNotes: notesWithoutNotebook.length,
|
||||
unorganizedNotes: notesWithoutNotebook.length,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Call AI to create organization plan
|
||||
const plan = await this.aiOrganizeNotes(notesWithoutNotebook, notebooks)
|
||||
|
||||
return plan
|
||||
}
|
||||
|
||||
/**
|
||||
* Use AI to analyze notes and create organization plan
|
||||
*/
|
||||
private async aiOrganizeNotes(
|
||||
notes: NoteForOrganization[],
|
||||
notebooks: any[]
|
||||
): Promise<OrganizationPlan> {
|
||||
const prompt = this.buildPrompt(notes, notebooks)
|
||||
|
||||
try {
|
||||
const provider = getAIProvider()
|
||||
const response = await provider.generateText(prompt)
|
||||
|
||||
// Parse AI response
|
||||
const plan = this.parseAIResponse(response, notes, notebooks)
|
||||
|
||||
return plan
|
||||
} catch (error) {
|
||||
console.error('Failed to create organization plan:', error)
|
||||
// Return empty plan on error
|
||||
return {
|
||||
notebooks: [],
|
||||
totalNotes: notes.length,
|
||||
unorganizedNotes: notes.length,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build prompt for AI (always in French - interface language)
|
||||
*/
|
||||
private buildPrompt(notes: NoteForOrganization[], notebooks: any[]): string {
|
||||
const notebookList = notebooks
|
||||
.map(nb => {
|
||||
const labels = nb.labels.map((l: any) => l.name).join(', ')
|
||||
const count = nb._count?.notes || 0
|
||||
return `- ${nb.name} (${count} notes)${labels ? ` [labels: ${labels}]` : ''}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const notesList = notes
|
||||
.map((note, index) => {
|
||||
const title = note.title || 'Sans titre'
|
||||
const content = note.content.substring(0, 200)
|
||||
return `[${index}] "${title}": ${content}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `
|
||||
Tu es un assistant qui organise des notes en les regroupant par thématique dans des carnets.
|
||||
|
||||
CARNETS DISPONIBLES :
|
||||
${notebookList}
|
||||
|
||||
NOTES À ORGANISER (Notes générales) :
|
||||
${notesList}
|
||||
|
||||
TÂCHE :
|
||||
Analyse chaque note et propose le carnet le PLUS approprié.
|
||||
Considère :
|
||||
1. Le sujet/thème de la note (LE PLUS IMPORTANT)
|
||||
2. Les labels existants dans chaque carnet
|
||||
3. La cohérence thématique entre notes du même carnet
|
||||
|
||||
GUIDES DE CLASSIFICATION :
|
||||
- SPORT/EXERCICE/ACHATS/COURSSES → Carnet Personnel
|
||||
- LOISIRS/PASSIONS/SORTIES → Carnet Personnel
|
||||
- SANTÉ/FITNESS/MÉDECIN → Carnet Personnel ou Santé
|
||||
- FAMILLE/AMIS → Carnet Personnel
|
||||
- TRAVAIL/RÉUNIONS/PROJETS/CLIENTS → Carnet Travail
|
||||
- CODING/TECH/DÉVELOPPEMENT → Carnet Travail ou Code
|
||||
- FINANCES/FACTURES/BANQUE → Carnet Personnel ou Finances
|
||||
|
||||
FORMAT DE RÉPONSE (JSON) :
|
||||
Pour chaque carnet, liste les notes qui lui appartiennent :
|
||||
{
|
||||
"carnets": [
|
||||
{
|
||||
"nom": "Nom du carnet",
|
||||
"notes": [
|
||||
{
|
||||
"index": 0,
|
||||
"confiance": 0.95,
|
||||
"raison": "Courte explication"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
RÈGLES :
|
||||
- Seules les notes avec confiance > 0.60 doivent être assignées
|
||||
- Si une note est trop générique, ne l'assigne pas
|
||||
- Sois précis dans tes regroupements thématiques
|
||||
|
||||
Ta réponse (JSON seulement) :
|
||||
`.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse AI response into OrganizationPlan
|
||||
*/
|
||||
private parseAIResponse(
|
||||
response: string,
|
||||
notes: NoteForOrganization[],
|
||||
notebooks: any[]
|
||||
): OrganizationPlan {
|
||||
try {
|
||||
// Try to parse JSON response
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/)
|
||||
if (!jsonMatch) {
|
||||
throw new Error('No JSON found in response')
|
||||
}
|
||||
|
||||
const aiData = JSON.parse(jsonMatch[0])
|
||||
|
||||
const notebookOrganizations: NotebookOrganization[] = []
|
||||
|
||||
// Process each notebook in AI response
|
||||
for (const aiNotebook of aiData.carnets || []) {
|
||||
const notebook = notebooks.find(nb => nb.name === aiNotebook.nom)
|
||||
if (!notebook) continue
|
||||
|
||||
const noteAssignments = aiNotebook.notes
|
||||
.filter((n: any) => n.confiance > 0.60) // Only high confidence
|
||||
.map((n: any) => {
|
||||
const note = notes[n.index]
|
||||
if (!note) return null
|
||||
|
||||
return {
|
||||
noteId: note.id,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
confidence: n.confiance,
|
||||
reason: n.raison || '',
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
if (noteAssignments.length > 0) {
|
||||
notebookOrganizations.push({
|
||||
notebookId: notebook.id,
|
||||
notebookName: notebook.name,
|
||||
notebookIcon: notebook.icon,
|
||||
notebookColor: notebook.color,
|
||||
notes: noteAssignments,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Count unorganized notes
|
||||
const organizedNoteIds = new Set(
|
||||
notebookOrganizations.flatMap(nb => nb.notes.map(n => n.noteId))
|
||||
)
|
||||
const unorganizedCount = notes.length - organizedNoteIds.size
|
||||
|
||||
return {
|
||||
notebooks: notebookOrganizations,
|
||||
totalNotes: notes.length,
|
||||
unorganizedNotes: unorganizedCount,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse AI response:', error)
|
||||
return {
|
||||
notebooks: [],
|
||||
totalNotes: notes.length,
|
||||
unorganizedNotes: notes.length,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the organization plan (move notes to notebooks)
|
||||
* @param userId - User ID
|
||||
* @param plan - Organization plan to apply
|
||||
* @param selectedNoteIds - Specific note IDs to organize (user can deselect)
|
||||
* @returns Number of notes moved
|
||||
*/
|
||||
async applyOrganizationPlan(
|
||||
userId: string,
|
||||
plan: OrganizationPlan,
|
||||
selectedNoteIds: string[]
|
||||
): Promise<number> {
|
||||
let movedCount = 0
|
||||
|
||||
for (const notebookOrg of plan.notebooks) {
|
||||
// Filter notes that are selected
|
||||
const notesToMove = notebookOrg.notes.filter(n =>
|
||||
selectedNoteIds.includes(n.noteId)
|
||||
)
|
||||
|
||||
if (notesToMove.length === 0) continue
|
||||
|
||||
// Move notes to notebook
|
||||
await prisma.note.updateMany({
|
||||
where: {
|
||||
id: { in: notesToMove.map(n => n.noteId) },
|
||||
userId,
|
||||
},
|
||||
data: {
|
||||
notebookId: notebookOrg.notebookId,
|
||||
},
|
||||
})
|
||||
|
||||
movedCount += notesToMove.length
|
||||
}
|
||||
|
||||
return movedCount
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const batchOrganizationService = new BatchOrganizationService()
|
||||
311
keep-notes/lib/ai/services/contextual-auto-tag.service.ts
Normal file
311
keep-notes/lib/ai/services/contextual-auto-tag.service.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Contextual Auto-Tagging Service (IA2)
|
||||
* Suggests labels from the current notebook's existing labels
|
||||
* OR creates new label suggestions for empty notebooks
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
|
||||
export interface LabelSuggestion {
|
||||
label: string
|
||||
confidence: number // 0-100
|
||||
reasoning?: string
|
||||
isNewLabel?: boolean // true if this is a suggestion to CREATE a new label
|
||||
}
|
||||
|
||||
export class ContextualAutoTagService {
|
||||
/**
|
||||
* Suggest labels for a note
|
||||
* @param noteContent - Content of the note
|
||||
* @param notebookId - ID of the notebook (to get available labels)
|
||||
* @param userId - User ID
|
||||
* @returns Array of label suggestions (max 3)
|
||||
*/
|
||||
async suggestLabels(
|
||||
noteContent: string,
|
||||
notebookId: string | null,
|
||||
userId: string
|
||||
): Promise<LabelSuggestion[]> {
|
||||
// If no notebook, return empty (no context)
|
||||
if (!notebookId) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get notebook with its labels
|
||||
const notebook = await prisma.notebook.findFirst({
|
||||
where: {
|
||||
id: notebookId,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
labels: {
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!notebook) {
|
||||
return []
|
||||
}
|
||||
|
||||
// CASE 1: Notebook has existing labels → suggest from them (IA2)
|
||||
if (notebook.labels.length > 0) {
|
||||
return await this.suggestFromExistingLabels(noteContent, notebook)
|
||||
}
|
||||
|
||||
// CASE 2: Notebook has NO labels → suggest NEW labels to create
|
||||
return await this.suggestNewLabels(noteContent, notebook)
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest labels from existing labels in the notebook (IA2)
|
||||
*/
|
||||
private async suggestFromExistingLabels(
|
||||
noteContent: string,
|
||||
notebook: any
|
||||
): Promise<LabelSuggestion[]> {
|
||||
const availableLabels = notebook.labels.map((l: any) => l.name)
|
||||
|
||||
// Build prompt with available labels
|
||||
const prompt = this.buildPrompt(noteContent, notebook.name, availableLabels)
|
||||
|
||||
try {
|
||||
const provider = getAIProvider()
|
||||
|
||||
// Use generateText with JSON response
|
||||
const response = await provider.generateText(prompt)
|
||||
|
||||
// Improved JSON parsing with multiple fallback strategies
|
||||
let parsed: any
|
||||
|
||||
// Strategy 1: Direct parse
|
||||
try {
|
||||
parsed = JSON.parse(response)
|
||||
} catch (e) {
|
||||
// Strategy 2: Extract JSON from markdown code blocks
|
||||
const codeBlockMatch = response.match(/```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```/)
|
||||
if (codeBlockMatch) {
|
||||
parsed = JSON.parse(codeBlockMatch[1])
|
||||
} else {
|
||||
// Strategy 3: Extract JSON object or array
|
||||
const jsonArrayMatch = response.match(/\[[\s\S]*\]/)
|
||||
const jsonObjectMatch = response.match(/\{[\s\S]*\}/)
|
||||
|
||||
if (jsonArrayMatch) {
|
||||
let cleanedJson = jsonArrayMatch[0]
|
||||
cleanedJson = cleanedJson.replace(/,\s*([}\]])/g, '$1')
|
||||
cleanedJson = cleanedJson.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":')
|
||||
parsed = JSON.parse(cleanedJson)
|
||||
} else if (jsonObjectMatch) {
|
||||
let cleanedJson = jsonObjectMatch[0]
|
||||
cleanedJson = cleanedJson.replace(/,\s*([}\]])/g, '$1')
|
||||
cleanedJson = cleanedJson.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":')
|
||||
parsed = JSON.parse(cleanedJson)
|
||||
} else {
|
||||
console.error('❌ Could not extract JSON from response')
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle both formats: array directly OR {suggestions: array}
|
||||
let suggestionsArray = parsed
|
||||
if (parsed.suggestions && Array.isArray(parsed.suggestions)) {
|
||||
suggestionsArray = parsed.suggestions
|
||||
} else if (Array.isArray(parsed)) {
|
||||
suggestionsArray = parsed
|
||||
} else {
|
||||
console.error('❌ Invalid response structure:', parsed)
|
||||
return []
|
||||
}
|
||||
|
||||
// Filter and map suggestions
|
||||
const suggestions = suggestionsArray
|
||||
.filter((s: any) => {
|
||||
// Must be in available labels
|
||||
return availableLabels.includes(s.label) && s.confidence > 0.6
|
||||
})
|
||||
.map((s: any) => ({
|
||||
label: s.label,
|
||||
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
|
||||
|
||||
return suggestions as LabelSuggestion[]
|
||||
} catch (error) {
|
||||
console.error('Failed to suggest labels:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest NEW labels to create for empty notebooks (Hybrid IA2+IA4)
|
||||
*/
|
||||
private async suggestNewLabels(
|
||||
noteContent: string,
|
||||
notebook: any
|
||||
): Promise<LabelSuggestion[]> {
|
||||
// Build prompt to suggest NEW labels based on content
|
||||
const prompt = this.buildNewLabelsPrompt(noteContent, notebook.name)
|
||||
|
||||
try {
|
||||
const provider = getAIProvider()
|
||||
|
||||
// Use generateText with JSON response
|
||||
const response = await provider.generateText(prompt)
|
||||
|
||||
// Improved JSON parsing with multiple fallback strategies
|
||||
let parsed: any
|
||||
|
||||
// Strategy 1: Direct parse
|
||||
try {
|
||||
parsed = JSON.parse(response)
|
||||
} catch (e) {
|
||||
// Strategy 2: Extract JSON from markdown code blocks
|
||||
const codeBlockMatch = response.match(/```(?:json)?\s*(\{[\s\S]*?\}|\[[\s\S]*?\])\s*```/)
|
||||
if (codeBlockMatch) {
|
||||
parsed = JSON.parse(codeBlockMatch[1])
|
||||
} else {
|
||||
// Strategy 3: Extract JSON object or array
|
||||
const jsonArrayMatch = response.match(/\[[\s\S]*\]/)
|
||||
const jsonObjectMatch = response.match(/\{[\s\S]*\}/)
|
||||
|
||||
if (jsonArrayMatch) {
|
||||
let cleanedJson = jsonArrayMatch[0]
|
||||
cleanedJson = cleanedJson.replace(/,\s*([}\]])/g, '$1')
|
||||
cleanedJson = cleanedJson.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":')
|
||||
parsed = JSON.parse(cleanedJson)
|
||||
} else if (jsonObjectMatch) {
|
||||
let cleanedJson = jsonObjectMatch[0]
|
||||
cleanedJson = cleanedJson.replace(/,\s*([}\]])/g, '$1')
|
||||
cleanedJson = cleanedJson.replace(/([{,]\s*)([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g, '$1"$2":')
|
||||
parsed = JSON.parse(cleanedJson)
|
||||
} else {
|
||||
console.error('❌ Could not extract JSON from response')
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle both formats: array directly OR {suggestions: array}
|
||||
let suggestionsArray = parsed
|
||||
if (parsed.suggestions && Array.isArray(parsed.suggestions)) {
|
||||
suggestionsArray = parsed.suggestions
|
||||
} else if (Array.isArray(parsed)) {
|
||||
suggestionsArray = parsed
|
||||
} else {
|
||||
console.error('❌ Invalid response structure:', parsed)
|
||||
return []
|
||||
}
|
||||
|
||||
// Filter and map suggestions
|
||||
const suggestions = suggestionsArray
|
||||
.filter((s: any) => {
|
||||
return s.label && s.label.length > 0 && s.confidence > 0.6
|
||||
})
|
||||
.map((s: any) => ({
|
||||
label: s.label,
|
||||
confidence: Math.round(s.confidence * 100),
|
||||
reasoning: s.reasoning || '',
|
||||
isNewLabel: true, // Mark as new label suggestion
|
||||
}))
|
||||
.sort((a: any, b: any) => b.confidence - a.confidence)
|
||||
.slice(0, 3) // Max 3 suggestions
|
||||
|
||||
return suggestions as LabelSuggestion[]
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to suggest new labels:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the AI prompt for contextual label suggestion
|
||||
*/
|
||||
private buildPrompt(noteContent: string, notebookName: string, availableLabels: string[]): string {
|
||||
const labelList = availableLabels.map(l => `- ${l}`).join('\n')
|
||||
|
||||
return `
|
||||
Tu es un assistant qui suggère les labels les plus appropriés pour une note.
|
||||
|
||||
CONTENU DE LA NOTE :
|
||||
${noteContent.substring(0, 1000)}
|
||||
|
||||
NOTEBOOK ACTUEL :
|
||||
${notebookName}
|
||||
|
||||
LABELS DISPONIBLES DANS CE NOTEBOOK :
|
||||
${labelList}
|
||||
|
||||
TÂCHE :
|
||||
Analyse le contenu de la note et suggère les labels les PLUS appropriés parmi les labels disponibles ci-dessus.
|
||||
Considère :
|
||||
1. La pertinence du label pour le contenu
|
||||
2. Le nombre de labels (maximum 3 suggestions)
|
||||
3. La confiance (seuil minimum : 0.6)
|
||||
|
||||
RÈGLES :
|
||||
- Suggère SEULEMENT des labels qui sont dans la liste des labels disponibles
|
||||
- Retourne au maximum 3 suggestions
|
||||
- Chaque suggestion doit avoir une confiance > 0.6
|
||||
- Si aucun label n'est pertinent, retourne un tableau vide
|
||||
|
||||
FORMAT DE RÉPONSE (JSON uniquement) :
|
||||
{
|
||||
"suggestions": [
|
||||
{ "label": "nom_du_label", "confidence": 0.85, "reasoning": "Pourquoi ce label est pertinent" }
|
||||
]
|
||||
}
|
||||
|
||||
Ta réponse :
|
||||
`.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the AI prompt for NEW label suggestions (when notebook is empty)
|
||||
*/
|
||||
private buildNewLabelsPrompt(noteContent: string, notebookName: string): string {
|
||||
return `
|
||||
Tu es un assistant qui suggère de nouveaux labels pour organiser une note.
|
||||
|
||||
CONTENU DE LA NOTE :
|
||||
${noteContent.substring(0, 1000)}
|
||||
|
||||
NOTEBOOK ACTUEL :
|
||||
${notebookName}
|
||||
|
||||
CONTEXTE :
|
||||
Ce notebook n'a pas encore de labels. Tu dois suggérer les PREMIERS labels appropriés pour cette note.
|
||||
|
||||
TÂCHE :
|
||||
Analyse le contenu de la note et suggère 1-3 labels qui seraient pertinents pour organiser cette note.
|
||||
Considère :
|
||||
1. Les sujets ou thèmes abordés
|
||||
2. Le type de contenu (idée, tâche, référence, etc.)
|
||||
3. Le contexte du notebook "${notebookName}"
|
||||
|
||||
RÈGLES :
|
||||
- Les labels doivent être COURTS (1-2 mots maximum)
|
||||
- Les labels doivent être en minuscules
|
||||
- Évite les accents si possible (ex: "idee" au lieu de "idée")
|
||||
- Retourne au maximum 3 suggestions
|
||||
- Chaque suggestion doit avoir une confiance > 0.6
|
||||
|
||||
IMPORTANT : Réponds UNIQUEMENT avec du JSON valide, sans texte avant ou après. Pas de markdown, pas de code blocks.
|
||||
|
||||
FORMAT DE RÉPONSE (JSON brut, sans markdown) :
|
||||
{"suggestions":[{"label":"nom_du_label","confidence":0.85,"reasoning":"Pourquoi ce label est pertinent"}]}
|
||||
|
||||
Ta réponse (JSON brut uniquement) :
|
||||
`.trim()
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const contextualAutoTagService = new ContextualAutoTagService()
|
||||
224
keep-notes/lib/ai/services/embedding.service.ts
Normal file
224
keep-notes/lib/ai/services/embedding.service.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Embedding Service
|
||||
* Generates vector embeddings for semantic search and similarity analysis
|
||||
* Uses text-embedding-3-small model via OpenAI (or Ollama alternatives)
|
||||
*/
|
||||
|
||||
import { getAIProvider } from '../factory'
|
||||
|
||||
export interface EmbeddingResult {
|
||||
embedding: number[]
|
||||
model: string
|
||||
dimension: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for generating and managing text embeddings
|
||||
*/
|
||||
export class EmbeddingService {
|
||||
private readonly EMBEDDING_MODEL = 'text-embedding-3-small'
|
||||
private readonly EMBEDDING_DIMENSION = 1536 // OpenAI's embedding dimension
|
||||
|
||||
/**
|
||||
* Generate embedding for a single text
|
||||
*/
|
||||
async generateEmbedding(text: string): Promise<EmbeddingResult> {
|
||||
if (!text || text.trim().length === 0) {
|
||||
throw new Error('Cannot generate embedding for empty text')
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = getAIProvider()
|
||||
|
||||
// Use the existing getEmbeddings method from AIProvider
|
||||
const embedding = await provider.getEmbeddings(text)
|
||||
|
||||
// Validate embedding dimension
|
||||
if (embedding.length !== this.EMBEDDING_DIMENSION) {
|
||||
}
|
||||
|
||||
return {
|
||||
embedding,
|
||||
model: this.EMBEDDING_MODEL,
|
||||
dimension: embedding.length
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating embedding:', error)
|
||||
throw new Error(`Failed to generate embedding: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embeddings for multiple texts in batch
|
||||
* More efficient than calling generateEmbedding multiple times
|
||||
*/
|
||||
async generateBatchEmbeddings(texts: string[]): Promise<EmbeddingResult[]> {
|
||||
if (!texts || texts.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Filter out empty texts
|
||||
const validTexts = texts.filter(t => t && t.trim().length > 0)
|
||||
|
||||
if (validTexts.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const provider = getAIProvider()
|
||||
|
||||
// Batch embedding using the existing getEmbeddings method
|
||||
const embeddings = await Promise.all(
|
||||
validTexts.map(text => provider.getEmbeddings(text))
|
||||
)
|
||||
|
||||
return embeddings.map(embedding => ({
|
||||
embedding,
|
||||
model: this.EMBEDDING_MODEL,
|
||||
dimension: embedding.length
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Error generating batch embeddings:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cosine similarity between two embeddings
|
||||
* Returns value between -1 and 1, where 1 is identical
|
||||
*/
|
||||
calculateCosineSimilarity(embedding1: number[], embedding2: number[]): number {
|
||||
if (embedding1.length !== embedding2.length) {
|
||||
throw new Error('Embeddings must have the same dimension')
|
||||
}
|
||||
|
||||
let dotProduct = 0
|
||||
let magnitude1 = 0
|
||||
let magnitude2 = 0
|
||||
|
||||
for (let i = 0; i < embedding1.length; i++) {
|
||||
dotProduct += embedding1[i] * embedding2[i]
|
||||
magnitude1 += embedding1[i] * embedding1[i]
|
||||
magnitude2 += embedding2[i] * embedding2[i]
|
||||
}
|
||||
|
||||
magnitude1 = Math.sqrt(magnitude1)
|
||||
magnitude2 = Math.sqrt(magnitude2)
|
||||
|
||||
if (magnitude1 === 0 || magnitude2 === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return dotProduct / (magnitude1 * magnitude2)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate similarity between an embedding and multiple other embeddings
|
||||
* Returns array of similarities
|
||||
*/
|
||||
calculateSimilarities(
|
||||
queryEmbedding: number[],
|
||||
targetEmbeddings: number[][]
|
||||
): number[] {
|
||||
return targetEmbeddings.map(embedding =>
|
||||
this.calculateCosineSimilarity(queryEmbedding, embedding)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find most similar embeddings to a query
|
||||
* Returns top-k results with their similarities
|
||||
*/
|
||||
findMostSimilar(
|
||||
queryEmbedding: number[],
|
||||
targetEmbeddings: Array<{ id: string; embedding: number[] }>,
|
||||
topK: number = 10
|
||||
): Array<{ id: string; similarity: number }> {
|
||||
const similarities = targetEmbeddings.map(({ id, embedding }) => ({
|
||||
id,
|
||||
similarity: this.calculateCosineSimilarity(queryEmbedding, embedding)
|
||||
}))
|
||||
|
||||
// Sort by similarity descending and return top-k
|
||||
return similarities
|
||||
.sort((a, b) => b.similarity - a.similarity)
|
||||
.slice(0, topK)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get average embedding from multiple embeddings
|
||||
* Useful for clustering or centroid calculation
|
||||
*/
|
||||
averageEmbeddings(embeddings: number[][]): number[] {
|
||||
if (embeddings.length === 0) {
|
||||
throw new Error('Cannot average empty embeddings array')
|
||||
}
|
||||
|
||||
const dimension = embeddings[0].length
|
||||
const average = new Array(dimension).fill(0)
|
||||
|
||||
for (const embedding of embeddings) {
|
||||
if (embedding.length !== dimension) {
|
||||
throw new Error('All embeddings must have the same dimension')
|
||||
}
|
||||
|
||||
for (let i = 0; i < dimension; i++) {
|
||||
average[i] += embedding[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Divide by number of embeddings
|
||||
return average.map(val => val / embeddings.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize embedding to JSON-safe format (for storage)
|
||||
*/
|
||||
serialize(embedding: number[]): string {
|
||||
return JSON.stringify(embedding)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize embedding from JSON string
|
||||
*/
|
||||
deserialize(jsonString: string): number[] {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString)
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error('Invalid embedding format')
|
||||
}
|
||||
return parsed
|
||||
} catch (error) {
|
||||
console.error('Error deserializing embedding:', error)
|
||||
throw new Error('Failed to deserialize embedding')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a note needs embedding regeneration
|
||||
* (e.g., if content has changed significantly)
|
||||
*/
|
||||
shouldRegenerateEmbedding(
|
||||
noteContent: string,
|
||||
lastEmbeddingContent: string | null,
|
||||
lastAnalysis: Date | null
|
||||
): boolean {
|
||||
// If no previous embedding, generate one
|
||||
if (!lastEmbeddingContent || !lastAnalysis) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If content has changed more than 20% (simple heuristic)
|
||||
const contentChanged =
|
||||
Math.abs(noteContent.length - lastEmbeddingContent.length) / lastEmbeddingContent.length > 0.2
|
||||
|
||||
// If last analysis is more than 7 days old
|
||||
const daysSinceAnalysis = (Date.now() - lastAnalysis.getTime()) / (1000 * 60 * 60 * 24)
|
||||
const isStale = daysSinceAnalysis > 7
|
||||
|
||||
return contentChanged || isStale
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const embeddingService = new EmbeddingService()
|
||||
70
keep-notes/lib/ai/services/index.ts
Normal file
70
keep-notes/lib/ai/services/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* AI Services Index
|
||||
* Central exports for all AI-powered services
|
||||
*/
|
||||
|
||||
// Language Detection
|
||||
export { LanguageDetectionService } from './language-detection.service'
|
||||
|
||||
// Title Suggestions
|
||||
export {
|
||||
TitleSuggestionService,
|
||||
titleSuggestionService,
|
||||
type TitleSuggestion
|
||||
} from './title-suggestion.service'
|
||||
|
||||
// Embeddings
|
||||
export {
|
||||
EmbeddingService,
|
||||
embeddingService,
|
||||
type EmbeddingResult
|
||||
} from './embedding.service'
|
||||
|
||||
// Semantic Search
|
||||
export {
|
||||
SemanticSearchService,
|
||||
semanticSearchService,
|
||||
type SearchResult,
|
||||
type SearchOptions
|
||||
} from './semantic-search.service'
|
||||
|
||||
// Paragraph Refactor
|
||||
export {
|
||||
ParagraphRefactorService,
|
||||
paragraphRefactorService,
|
||||
type RefactorMode,
|
||||
type RefactorOption,
|
||||
type RefactorResult,
|
||||
REFACTOR_OPTIONS
|
||||
} from './paragraph-refactor.service'
|
||||
|
||||
// Memory Echo
|
||||
export {
|
||||
MemoryEchoService,
|
||||
memoryEchoService,
|
||||
type MemoryEchoInsight
|
||||
} from './memory-echo.service'
|
||||
|
||||
// Batch Organization
|
||||
export {
|
||||
BatchOrganizationService,
|
||||
batchOrganizationService,
|
||||
type NoteForOrganization,
|
||||
type NotebookOrganization,
|
||||
type OrganizationPlan
|
||||
} from './batch-organization.service'
|
||||
|
||||
// Auto Label Creation
|
||||
export {
|
||||
AutoLabelCreationService,
|
||||
autoLabelCreationService,
|
||||
type SuggestedLabel,
|
||||
type AutoLabelSuggestion
|
||||
} from './auto-label-creation.service'
|
||||
|
||||
// Notebook Summary
|
||||
export {
|
||||
NotebookSummaryService,
|
||||
notebookSummaryService,
|
||||
type NotebookSummary
|
||||
} from './notebook-summary.service'
|
||||
133
keep-notes/lib/ai/services/language-detection.service.ts
Normal file
133
keep-notes/lib/ai/services/language-detection.service.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { detect } from 'tinyld'
|
||||
|
||||
/**
|
||||
* Language Detection Service
|
||||
*
|
||||
* Uses hybrid approach:
|
||||
* - TinyLD for notes < 50 words (fast, ~8ms)
|
||||
* - AI for notes ≥ 50 words (more accurate, ~200-500ms)
|
||||
*
|
||||
* Supports 62 languages including Persian (fa)
|
||||
*/
|
||||
export class LanguageDetectionService {
|
||||
private readonly MIN_WORDS_FOR_AI = 50
|
||||
private readonly MIN_CONFIDENCE = 0.7
|
||||
|
||||
/**
|
||||
* Detect language of content using hybrid approach
|
||||
*/
|
||||
async detectLanguage(content: string): Promise<{
|
||||
language: string // 'fr' | 'en' | 'es' | 'de' | 'fa' | 'unknown'
|
||||
confidence: number // 0.0-1.0
|
||||
method: 'tinyld' | 'ai' | 'unknown'
|
||||
}> {
|
||||
if (!content || content.trim().length === 0) {
|
||||
return {
|
||||
language: 'unknown',
|
||||
confidence: 0.0,
|
||||
method: 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const wordCount = content.split(/\s+/).length
|
||||
|
||||
// Short notes: TinyLD (fast, TypeScript native)
|
||||
if (wordCount < this.MIN_WORDS_FOR_AI) {
|
||||
const result = detect(content)
|
||||
return {
|
||||
language: this.mapToISO(result),
|
||||
confidence: 0.8,
|
||||
method: 'tinyld'
|
||||
}
|
||||
}
|
||||
|
||||
// Long notes: AI for better accuracy
|
||||
try {
|
||||
const detected = await this.detectLanguageWithAI(content)
|
||||
|
||||
return {
|
||||
language: detected,
|
||||
confidence: 0.9,
|
||||
method: 'ai'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Language detection error:', error)
|
||||
|
||||
// Fallback to TinyLD
|
||||
const result = detect(content)
|
||||
return {
|
||||
language: this.mapToISO(result),
|
||||
confidence: 0.6,
|
||||
method: 'tinyld'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect language using AI provider
|
||||
* (Fallback method for long content)
|
||||
*/
|
||||
private async detectLanguageWithAI(content: string): Promise<string> {
|
||||
// For now, use TinyLD as AI detection is not yet implemented
|
||||
// In Phase 2, we can add AI-based detection for better accuracy
|
||||
const result = detect(content)
|
||||
return this.mapToISO(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map TinyLD language codes to ISO 639-1
|
||||
*/
|
||||
private mapToISO(code: string): string {
|
||||
const mapping: Record<string, string> = {
|
||||
'fra': 'fr',
|
||||
'eng': 'en',
|
||||
'spa': 'es',
|
||||
'deu': 'de',
|
||||
'fas': 'fa',
|
||||
'pes': 'fa', // Persian (Farsi)
|
||||
'por': 'pt',
|
||||
'ita': 'it',
|
||||
'rus': 'ru',
|
||||
'zho': 'zh',
|
||||
'jpn': 'ja',
|
||||
'kor': 'ko',
|
||||
'ara': 'ar',
|
||||
'hin': 'hi',
|
||||
'nld': 'nl',
|
||||
'pol': 'pl',
|
||||
'tur': 'tr',
|
||||
'vie': 'vi',
|
||||
'tha': 'th',
|
||||
'ind': 'id'
|
||||
}
|
||||
|
||||
// Direct mapping for ISO codes
|
||||
if (code.length === 2 && /^[a-z]{2}$/.test(code)) {
|
||||
return code
|
||||
}
|
||||
|
||||
// Use mapping or fallback
|
||||
return mapping[code] || code.substring(0, 2).toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get supported languages count
|
||||
*/
|
||||
getSupportedLanguagesCount(): number {
|
||||
return 62 // TinyLD supports 62 languages
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a language code is supported
|
||||
*/
|
||||
isLanguageSupported(languageCode: string): boolean {
|
||||
// TinyLD supports 62 languages including Persian (fa)
|
||||
const supportedCodes = [
|
||||
'fr', 'en', 'es', 'de', 'fa', 'pt', 'it', 'ru', 'zh',
|
||||
'ja', 'ko', 'ar', 'hi', 'nl', 'pl', 'tr', 'vi', 'th', 'id'
|
||||
// ... and 43 more
|
||||
]
|
||||
|
||||
return supportedCodes.includes(languageCode.toLowerCase())
|
||||
}
|
||||
}
|
||||
528
keep-notes/lib/ai/services/memory-echo.service.ts
Normal file
528
keep-notes/lib/ai/services/memory-echo.service.ts
Normal file
@@ -0,0 +1,528 @@
|
||||
import { getAIProvider } from '../factory'
|
||||
import { cosineSimilarity } from '@/lib/utils'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
export interface NoteConnection {
|
||||
note1: {
|
||||
id: string
|
||||
title: string | null
|
||||
content: string
|
||||
createdAt: Date
|
||||
}
|
||||
note2: {
|
||||
id: string
|
||||
title: string | null
|
||||
content: string | null
|
||||
createdAt: Date
|
||||
}
|
||||
similarityScore: number
|
||||
insight: string
|
||||
daysApart: number
|
||||
}
|
||||
|
||||
export interface MemoryEchoInsight {
|
||||
id: string
|
||||
note1Id: string
|
||||
note2Id: string
|
||||
note1: {
|
||||
id: string
|
||||
title: string | null
|
||||
content: string
|
||||
}
|
||||
note2: {
|
||||
id: string
|
||||
title: string | null
|
||||
content: string
|
||||
}
|
||||
similarityScore: number
|
||||
insight: string
|
||||
insightDate: Date
|
||||
viewed: boolean
|
||||
feedback: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory Echo Service - Proactive note connections
|
||||
* "I didn't search, it found me"
|
||||
*/
|
||||
export class MemoryEchoService {
|
||||
private readonly SIMILARITY_THRESHOLD = 0.75 // High threshold for quality connections
|
||||
private readonly SIMILARITY_THRESHOLD_DEMO = 0.50 // Lower threshold for demo mode
|
||||
private readonly MIN_DAYS_APART = 7 // Notes must be at least 7 days apart
|
||||
private readonly MIN_DAYS_APART_DEMO = 0 // No delay for demo mode
|
||||
private readonly MAX_INSIGHTS_PER_USER = 100 // Prevent spam
|
||||
|
||||
/**
|
||||
* Find meaningful connections between user's notes
|
||||
*/
|
||||
async findConnections(userId: string, demoMode: boolean = false): Promise<NoteConnection[]> {
|
||||
// Get all user's notes with embeddings
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId,
|
||||
isArchived: false,
|
||||
embedding: { not: null } // Only notes with embeddings
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
embedding: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
|
||||
if (notes.length < 2) {
|
||||
return [] // Need at least 2 notes to find connections
|
||||
}
|
||||
|
||||
// Parse embeddings
|
||||
const notesWithEmbeddings = notes
|
||||
.map(note => ({
|
||||
...note,
|
||||
embedding: note.embedding ? JSON.parse(note.embedding) : null
|
||||
}))
|
||||
.filter(note => note.embedding && Array.isArray(note.embedding))
|
||||
|
||||
const connections: NoteConnection[] = []
|
||||
|
||||
// Use demo mode parameters if enabled
|
||||
const minDaysApart = demoMode ? this.MIN_DAYS_APART_DEMO : this.MIN_DAYS_APART
|
||||
const similarityThreshold = demoMode ? this.SIMILARITY_THRESHOLD_DEMO : this.SIMILARITY_THRESHOLD
|
||||
|
||||
// Compare all pairs of notes
|
||||
for (let i = 0; i < notesWithEmbeddings.length; i++) {
|
||||
for (let j = i + 1; j < notesWithEmbeddings.length; j++) {
|
||||
const note1 = notesWithEmbeddings[i]
|
||||
const note2 = notesWithEmbeddings[j]
|
||||
|
||||
// Calculate time difference
|
||||
const daysApart = Math.abs(
|
||||
Math.floor((note1.createdAt.getTime() - note2.createdAt.getTime()) / (1000 * 60 * 60 * 24))
|
||||
)
|
||||
|
||||
// Time diversity filter: notes must be from different time periods
|
||||
if (daysApart < minDaysApart) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate cosine similarity
|
||||
const similarity = cosineSimilarity(note1.embedding, note2.embedding)
|
||||
|
||||
// Similarity threshold for meaningful connections
|
||||
if (similarity >= similarityThreshold) {
|
||||
connections.push({
|
||||
note1: {
|
||||
id: note1.id,
|
||||
title: note1.title,
|
||||
content: note1.content.substring(0, 200) + (note1.content.length > 200 ? '...' : ''),
|
||||
createdAt: note1.createdAt
|
||||
},
|
||||
note2: {
|
||||
id: note2.id,
|
||||
title: note2.title,
|
||||
content: note2.content ? note2.content.substring(0, 200) + (note2.content.length > 200 ? '...' : '') : '',
|
||||
createdAt: note2.createdAt
|
||||
},
|
||||
similarityScore: similarity,
|
||||
insight: '', // Will be generated by AI
|
||||
daysApart
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by similarity score (descending)
|
||||
connections.sort((a, b) => b.similarityScore - a.similarityScore)
|
||||
|
||||
// Return top connections
|
||||
return connections.slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI explanation for the connection
|
||||
*/
|
||||
async generateInsight(
|
||||
note1Title: string | null,
|
||||
note1Content: string,
|
||||
note2Title: string | null,
|
||||
note2Content: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const config = await prisma.systemConfig.findFirst()
|
||||
const provider = getAIProvider(config || undefined)
|
||||
|
||||
const note1Desc = note1Title || 'Untitled note'
|
||||
const note2Desc = note2Title || 'Untitled note'
|
||||
|
||||
const prompt = `You are a helpful assistant analyzing connections between notes.
|
||||
|
||||
Note 1: "${note1Desc}"
|
||||
Content: ${note1Content.substring(0, 300)}
|
||||
|
||||
Note 2: "${note2Desc}"
|
||||
Content: ${note2Content.substring(0, 300)}
|
||||
|
||||
Explain in one brief sentence (max 15 words) why these notes are connected. Focus on the semantic relationship.`
|
||||
|
||||
const response = await provider.generateText(prompt)
|
||||
|
||||
// Clean up response
|
||||
const insight = response
|
||||
.replace(/^["']|["']$/g, '') // Remove quotes
|
||||
.replace(/^[^.]+\.\s*/, '') // Remove "Here is..." prefix
|
||||
.trim()
|
||||
.substring(0, 150) // Max length
|
||||
|
||||
return insight || 'These notes appear to be semantically related.'
|
||||
|
||||
} catch (error) {
|
||||
console.error('[MemoryEcho] Failed to generate insight:', error)
|
||||
return 'These notes appear to be semantically related.'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next pending insight for user (based on frequency limit)
|
||||
*/
|
||||
async getNextInsight(userId: string): Promise<MemoryEchoInsight | null> {
|
||||
// Check if Memory Echo is enabled for user
|
||||
const settings = await prisma.userAISettings.findUnique({
|
||||
where: { userId }
|
||||
})
|
||||
|
||||
if (!settings || !settings.memoryEcho) {
|
||||
return null // Memory Echo disabled
|
||||
}
|
||||
|
||||
const demoMode = settings.demoMode || false
|
||||
|
||||
// Skip frequency checks in demo mode
|
||||
if (!demoMode) {
|
||||
// Check frequency limit
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
|
||||
const insightsShownToday = await prisma.memoryEchoInsight.count({
|
||||
where: {
|
||||
userId,
|
||||
insightDate: {
|
||||
gte: today
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Frequency limits
|
||||
const maxPerDay = settings.memoryEchoFrequency === 'daily' ? 1 :
|
||||
settings.memoryEchoFrequency === 'weekly' ? 0 : // 1 per 7 days (handled below)
|
||||
3 // custom = 3 per day
|
||||
|
||||
if (settings.memoryEchoFrequency === 'weekly') {
|
||||
// Check if shown in last 7 days
|
||||
const weekAgo = new Date(today)
|
||||
weekAgo.setDate(weekAgo.getDate() - 7)
|
||||
|
||||
const recentInsight = await prisma.memoryEchoInsight.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
insightDate: {
|
||||
gte: weekAgo
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (recentInsight) {
|
||||
return null // Already shown this week
|
||||
}
|
||||
} else if (insightsShownToday >= maxPerDay) {
|
||||
return null // Daily limit reached
|
||||
}
|
||||
|
||||
// Check total insights limit (prevent spam)
|
||||
const totalInsights = await prisma.memoryEchoInsight.count({
|
||||
where: { userId }
|
||||
})
|
||||
|
||||
if (totalInsights >= this.MAX_INSIGHTS_PER_USER) {
|
||||
return null // User has too many insights
|
||||
}
|
||||
}
|
||||
|
||||
// Find new connections (pass demoMode)
|
||||
const connections = await this.findConnections(userId, demoMode)
|
||||
|
||||
if (connections.length === 0) {
|
||||
return null // No connections found
|
||||
}
|
||||
|
||||
// Filter out already shown connections
|
||||
const existingInsights = await prisma.memoryEchoInsight.findMany({
|
||||
where: { userId },
|
||||
select: { note1Id: true, note2Id: true }
|
||||
})
|
||||
|
||||
const shownPairs = new Set(
|
||||
existingInsights.map(i => `${i.note1Id}-${i.note2Id}`)
|
||||
)
|
||||
|
||||
const newConnection = connections.find(c =>
|
||||
!shownPairs.has(`${c.note1.id}-${c.note2.id}`) &&
|
||||
!shownPairs.has(`${c.note2.id}-${c.note1.id}`)
|
||||
)
|
||||
|
||||
if (!newConnection) {
|
||||
return null // All connections already shown
|
||||
}
|
||||
|
||||
// Generate AI insight
|
||||
const insightText = await this.generateInsight(
|
||||
newConnection.note1.title,
|
||||
newConnection.note1.content,
|
||||
newConnection.note2.title,
|
||||
newConnection.note2.content || ''
|
||||
)
|
||||
|
||||
// Store insight in database
|
||||
const insight = await prisma.memoryEchoInsight.create({
|
||||
data: {
|
||||
userId,
|
||||
note1Id: newConnection.note1.id,
|
||||
note2Id: newConnection.note2.id,
|
||||
similarityScore: newConnection.similarityScore,
|
||||
insight: insightText,
|
||||
insightDate: new Date(),
|
||||
viewed: false
|
||||
},
|
||||
include: {
|
||||
note1: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true
|
||||
}
|
||||
},
|
||||
note2: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return insight
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark insight as viewed
|
||||
*/
|
||||
async markAsViewed(insightId: string): Promise<void> {
|
||||
await prisma.memoryEchoInsight.update({
|
||||
where: { id: insightId },
|
||||
data: { viewed: true }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit feedback for insight
|
||||
*/
|
||||
async submitFeedback(insightId: string, feedback: 'thumbs_up' | 'thumbs_down'): Promise<void> {
|
||||
await prisma.memoryEchoInsight.update({
|
||||
where: { id: insightId },
|
||||
data: { feedback }
|
||||
})
|
||||
|
||||
// Optional: Store in AiFeedback for analytics
|
||||
const insight = await prisma.memoryEchoInsight.findUnique({
|
||||
where: { id: insightId },
|
||||
select: { userId: true, note1Id: true }
|
||||
})
|
||||
|
||||
if (insight) {
|
||||
await prisma.aiFeedback.create({
|
||||
data: {
|
||||
noteId: insight.note1Id,
|
||||
userId: insight.userId,
|
||||
feedbackType: feedback,
|
||||
feature: 'memory_echo',
|
||||
originalContent: JSON.stringify({ insightId }),
|
||||
metadata: JSON.stringify({
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all connections for a specific note
|
||||
*/
|
||||
async getConnectionsForNote(noteId: string, userId: string): Promise<NoteConnection[]> {
|
||||
// Get the note with embedding
|
||||
const targetNote = await prisma.note.findUnique({
|
||||
where: { id: noteId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
embedding: true,
|
||||
createdAt: true,
|
||||
userId: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!targetNote || targetNote.userId !== userId) {
|
||||
return [] // Note not found or doesn't belong to user
|
||||
}
|
||||
|
||||
if (!targetNote.embedding) {
|
||||
return [] // Note has no embedding
|
||||
}
|
||||
|
||||
// Get dismissed connections for this note (to filter them out)
|
||||
const dismissedInsights = await prisma.memoryEchoInsight.findMany({
|
||||
where: {
|
||||
userId,
|
||||
dismissed: true,
|
||||
OR: [
|
||||
{ note1Id: noteId },
|
||||
{ note2Id: noteId }
|
||||
]
|
||||
},
|
||||
select: {
|
||||
note1Id: true,
|
||||
note2Id: true
|
||||
}
|
||||
})
|
||||
|
||||
// Create a set of dismissed note pairs for quick lookup
|
||||
const dismissedPairs = new Set(
|
||||
dismissedInsights.map(i =>
|
||||
`${i.note1Id}-${i.note2Id}`
|
||||
)
|
||||
)
|
||||
|
||||
// Get all other user's notes with embeddings
|
||||
const otherNotes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId,
|
||||
id: { not: noteId }, // Exclude the target note
|
||||
isArchived: false,
|
||||
embedding: { not: null }
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
embedding: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
})
|
||||
|
||||
if (otherNotes.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Parse target note embedding
|
||||
const targetEmbedding = JSON.parse(targetNote.embedding)
|
||||
|
||||
// Check if user has demo mode enabled
|
||||
const settings = await prisma.userAISettings.findUnique({
|
||||
where: { userId }
|
||||
})
|
||||
const demoMode = settings?.demoMode || false
|
||||
|
||||
const minDaysApart = demoMode ? this.MIN_DAYS_APART_DEMO : this.MIN_DAYS_APART
|
||||
const similarityThreshold = demoMode ? this.SIMILARITY_THRESHOLD_DEMO : this.SIMILARITY_THRESHOLD
|
||||
|
||||
const connections: NoteConnection[] = []
|
||||
|
||||
// Compare target note with all other notes
|
||||
for (const otherNote of otherNotes) {
|
||||
if (!otherNote.embedding) continue
|
||||
|
||||
const otherEmbedding = JSON.parse(otherNote.embedding)
|
||||
|
||||
// Check if this connection was dismissed
|
||||
const pairKey1 = `${targetNote.id}-${otherNote.id}`
|
||||
const pairKey2 = `${otherNote.id}-${targetNote.id}`
|
||||
if (dismissedPairs.has(pairKey1) || dismissedPairs.has(pairKey2)) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate time difference
|
||||
const daysApart = Math.abs(
|
||||
Math.floor((targetNote.createdAt.getTime() - otherNote.createdAt.getTime()) / (1000 * 60 * 60 * 24))
|
||||
)
|
||||
|
||||
// Time diversity filter
|
||||
if (daysApart < minDaysApart) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate cosine similarity
|
||||
const similarity = cosineSimilarity(targetEmbedding, otherEmbedding)
|
||||
|
||||
// Similarity threshold
|
||||
if (similarity >= similarityThreshold) {
|
||||
connections.push({
|
||||
note1: {
|
||||
id: targetNote.id,
|
||||
title: targetNote.title,
|
||||
content: targetNote.content.substring(0, 200) + (targetNote.content.length > 200 ? '...' : ''),
|
||||
createdAt: targetNote.createdAt
|
||||
},
|
||||
note2: {
|
||||
id: otherNote.id,
|
||||
title: otherNote.title,
|
||||
content: otherNote.content ? otherNote.content.substring(0, 200) + (otherNote.content.length > 200 ? '...' : '') : '',
|
||||
createdAt: otherNote.createdAt
|
||||
},
|
||||
similarityScore: similarity,
|
||||
insight: '', // Will be generated on demand
|
||||
daysApart
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by similarity score (descending)
|
||||
connections.sort((a, b) => b.similarityScore - a.similarityScore)
|
||||
|
||||
return connections
|
||||
}
|
||||
|
||||
/**
|
||||
* Get insights history for user
|
||||
*/
|
||||
async getInsightsHistory(userId: string): Promise<MemoryEchoInsight[]> {
|
||||
const insights = await prisma.memoryEchoInsight.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
note1: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true
|
||||
}
|
||||
},
|
||||
note2: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { insightDate: 'desc' },
|
||||
take: 20
|
||||
})
|
||||
|
||||
return insights
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const memoryEchoService = new MemoryEchoService()
|
||||
152
keep-notes/lib/ai/services/notebook-suggestion.service.ts
Normal file
152
keep-notes/lib/ai/services/notebook-suggestion.service.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
import type { Notebook } from '@/lib/types'
|
||||
|
||||
export class NotebookSuggestionService {
|
||||
/**
|
||||
* Suggest the most appropriate notebook for a note
|
||||
* @param noteContent - Content of the note
|
||||
* @param userId - User ID (for fetching user's notebooks)
|
||||
* @returns Suggested notebook or null (if no good match)
|
||||
*/
|
||||
async suggestNotebook(noteContent: string, userId: string): Promise<Notebook | null> {
|
||||
// 1. Get all notebooks for this user
|
||||
const notebooks = await prisma.notebook.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
labels: true,
|
||||
_count: {
|
||||
select: { notes: true },
|
||||
},
|
||||
},
|
||||
orderBy: { order: 'asc' },
|
||||
})
|
||||
|
||||
if (notebooks.length === 0) {
|
||||
return null // No notebooks to suggest
|
||||
}
|
||||
|
||||
// 2. Build prompt for AI (always in French - interface language)
|
||||
const prompt = this.buildPrompt(noteContent, notebooks)
|
||||
|
||||
// 3. Call AI
|
||||
try {
|
||||
const provider = getAIProvider()
|
||||
|
||||
const response = await provider.generateText(prompt)
|
||||
|
||||
const suggestedName = response.trim().toUpperCase()
|
||||
|
||||
// 5. Find matching notebook
|
||||
const suggestedNotebook = notebooks.find(nb =>
|
||||
nb.name.toUpperCase() === suggestedName
|
||||
)
|
||||
|
||||
// If AI says "NONE" or no match, return null
|
||||
if (suggestedName === 'NONE' || !suggestedNotebook) {
|
||||
return null
|
||||
}
|
||||
|
||||
return suggestedNotebook as Notebook
|
||||
} catch (error) {
|
||||
console.error('Failed to suggest notebook:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the AI prompt for notebook suggestion (always in French - interface language)
|
||||
*/
|
||||
private buildPrompt(noteContent: string, notebooks: any[]): string {
|
||||
const notebookList = notebooks
|
||||
.map(nb => {
|
||||
const labels = nb.labels.map((l: any) => l.name).join(', ')
|
||||
const count = nb._count?.notes || 0
|
||||
return `- ${nb.name} (${count} notes)${labels ? ` [labels: ${labels}]` : ''}`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
return `
|
||||
Tu es un assistant qui suggère à quel carnet une note devrait appartenir.
|
||||
|
||||
CONTENU DE LA NOTE :
|
||||
${noteContent.substring(0, 500)}
|
||||
|
||||
CARNETS DISPONIBLES :
|
||||
${notebookList}
|
||||
|
||||
TÂCHE :
|
||||
Analyse le contenu de la note (peu importe la langue) et suggère le carnet le PLUS approprié pour cette note.
|
||||
Considère :
|
||||
1. Le sujet/thème de la note (LE PLUS IMPORTANT)
|
||||
2. Les labels existants dans chaque carnet
|
||||
3. Le nombre de notes (préfère les carnets avec du contenu connexe)
|
||||
|
||||
GUIDES DE CLASSIFICATION :
|
||||
- SPORT/EXERCICE/ACHATS/COURSSES → Carnet Personnel
|
||||
- LOISIRS/PASSIONS/SORTIES → Carnet Personnel
|
||||
- SANTÉ/FITNESS/MÉDECIN → Carnet Personnel ou Santé
|
||||
- FAMILLE/AMIS → Carnet Personnel
|
||||
- TRAVAIL/RÉUNIONS/PROJETS/CLIENTS → Carnet Travail
|
||||
- CODING/TECH/DÉVELOPPEMENT → Carnet Travail ou Code
|
||||
- FINANCES/FACTURES/BANQUE → Carnet Personnel ou Finances
|
||||
|
||||
RÈGLES :
|
||||
- Retourne SEULEMENT le nom du carnet, EXACTEMENT comme indiqué ci-dessus (insensible à la casse)
|
||||
- Si aucune bonne correspondance n'existe, retourne "NONE"
|
||||
- Si la note est trop générique/vague, retourne "NONE"
|
||||
- N'inclus pas d'explications ou de texte supplémentaire
|
||||
|
||||
Exemples :
|
||||
- "Réunion avec Jean sur le planning du projet" → carnet "Travail"
|
||||
- "Liste de courses ou achat de vêtements" → carnet "Personnel"
|
||||
- "Script Python pour analyse de données" → carnet "Code"
|
||||
- "Séance de sport ou fitness" → carnet "Personnel"
|
||||
- "Achat d'une chemise et d'un jean" → carnet "Personnel"
|
||||
|
||||
Ta suggestion :
|
||||
`.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch suggest notebooks for multiple notes (IA3)
|
||||
* @param noteContents - Array of note contents
|
||||
* @param userId - User ID
|
||||
* @returns Map of note index -> suggested notebook
|
||||
*/
|
||||
async suggestNotebooksBatch(
|
||||
noteContents: string[],
|
||||
userId: string
|
||||
): Promise<Map<number, Notebook | null>> {
|
||||
const results = new Map<number, Notebook | null>()
|
||||
|
||||
// For efficiency, we could batch this into a single AI call
|
||||
// For now, process sequentially (could be parallelized)
|
||||
for (let i = 0; i < noteContents.length; i++) {
|
||||
const suggestion = await this.suggestNotebook(noteContents[i], userId)
|
||||
results.set(i, suggestion)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notebook suggestion confidence score
|
||||
* (For future UI enhancement: show confidence level)
|
||||
*/
|
||||
async suggestNotebookWithConfidence(
|
||||
noteContent: string,
|
||||
userId: string
|
||||
): Promise<{ notebook: Notebook | null; confidence: number }> {
|
||||
// This could use logprobs from OpenAI API to calculate confidence
|
||||
// For now, return binary confidence
|
||||
const notebook = await this.suggestNotebook(noteContent, userId)
|
||||
return {
|
||||
notebook,
|
||||
confidence: notebook ? 0.8 : 0, // Fixed confidence for now
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const notebookSuggestionService = new NotebookSuggestionService()
|
||||
189
keep-notes/lib/ai/services/notebook-summary.service.ts
Normal file
189
keep-notes/lib/ai/services/notebook-summary.service.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getAIProvider } from '@/lib/ai/factory'
|
||||
|
||||
export interface NotebookSummary {
|
||||
notebookId: string
|
||||
notebookName: string
|
||||
notebookIcon: string | null
|
||||
summary: string // Markdown formatted summary
|
||||
stats: {
|
||||
totalNotes: number
|
||||
totalLabels: number
|
||||
labelsUsed: string[]
|
||||
}
|
||||
generatedAt: Date
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for generating AI-powered notebook summaries
|
||||
* (Story 5.6 - IA6)
|
||||
*/
|
||||
export class NotebookSummaryService {
|
||||
/**
|
||||
* Generate a summary for a notebook
|
||||
* @param notebookId - Notebook ID
|
||||
* @param userId - User ID (for authorization)
|
||||
* @returns Notebook summary or null
|
||||
*/
|
||||
async generateSummary(notebookId: string, userId: string): Promise<NotebookSummary | null> {
|
||||
// 1. Get notebook with notes and 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')
|
||||
}
|
||||
|
||||
// Get all notes in this notebook
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
notebookId,
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
labelRelations: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc',
|
||||
},
|
||||
take: 100, // Limit to 100 most recent notes for summary
|
||||
})
|
||||
|
||||
if (notes.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 2. Generate summary using AI
|
||||
const summary = await this.generateAISummary(notes, notebook)
|
||||
|
||||
// 3. Get labels used in this notebook
|
||||
const labelsUsed = Array.from(
|
||||
new Set(
|
||||
notes.flatMap(note =>
|
||||
note.labelRelations.map(l => l.name)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
notebookId: notebook.id,
|
||||
notebookName: notebook.name,
|
||||
notebookIcon: notebook.icon,
|
||||
summary,
|
||||
stats: {
|
||||
totalNotes: notebook._count.notes,
|
||||
totalLabels: notebook.labels.length,
|
||||
labelsUsed,
|
||||
},
|
||||
generatedAt: new Date(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use AI to generate notebook summary
|
||||
*/
|
||||
private async generateAISummary(notes: any[], notebook: any): Promise<string> {
|
||||
// Build notes summary for AI
|
||||
const notesSummary = notes
|
||||
.map((note, index) => {
|
||||
const title = note.title || 'Sans titre'
|
||||
const content = note.content.substring(0, 200) // Limit content length
|
||||
const labels = note.labelRelations.map((l: any) => l.name).join(', ')
|
||||
const date = new Date(note.createdAt).toLocaleDateString('fr-FR')
|
||||
|
||||
return `[${index + 1}] **${title}** (${date})
|
||||
${labels ? `Labels: ${labels}` : ''}
|
||||
${content}...`
|
||||
})
|
||||
.join('\n\n')
|
||||
|
||||
const prompt = this.buildPrompt(notesSummary, notebook.name)
|
||||
|
||||
try {
|
||||
const provider = getAIProvider()
|
||||
const summary = await provider.generateText(prompt)
|
||||
return summary.trim()
|
||||
} catch (error) {
|
||||
console.error('Failed to generate notebook summary:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build prompt for AI (always in French - interface language)
|
||||
*/
|
||||
private buildPrompt(notesSummary: string, notebookName: string): string {
|
||||
return `
|
||||
Tu es un assistant qui génère des synthèses structurées de carnets de notes.
|
||||
|
||||
CARNET: ${notebookName}
|
||||
|
||||
NOTES DU CARNET:
|
||||
${notesSummary}
|
||||
|
||||
TÂCHE:
|
||||
Génère une synthèse structurée et organisée de ce carnet en analysant toutes les notes.
|
||||
|
||||
FORMAT DE LA RÉPONSE (Markdown avec emojis):
|
||||
|
||||
# 📊 Synthèse du Carnet ${notebookName}
|
||||
|
||||
## 🌍 Thèmes Principaux
|
||||
• Identifie 3-5 thèmes récurrents ou sujets abordés
|
||||
|
||||
## 📝 Statistiques
|
||||
• Nombre total de notes analysées
|
||||
• Principales catégories de contenu
|
||||
|
||||
## 📅 Éléments Temporels
|
||||
• Dates ou périodes importantes mentionnées
|
||||
• Événements planifiés vs passés
|
||||
|
||||
## ⚠️ Points d'Attention / Actions Requises
|
||||
• Tâches ou actions identifiées dans les notes
|
||||
• Rappels ou échéances importantes
|
||||
• Éléments nécessitant une attention particulière
|
||||
|
||||
## 💡 Insights Clés
|
||||
• Résumé des informations les plus importantes
|
||||
• Tendances ou patterns observés
|
||||
• Connexions entre les différentes notes
|
||||
|
||||
RÈGLES:
|
||||
- Utilise le format Markdown avec emojis comme dans l'exemple
|
||||
- Sois concis et organise l'information de manière claire
|
||||
- Identifie les vraies tendances, ne pas inventer d'informations
|
||||
- Si une section n'est pas pertinente, utilise "N/A" ou omets-la
|
||||
- Ton: professionnel mais accessible
|
||||
|
||||
Ta réponse :
|
||||
`.trim()
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const notebookSummaryService = new NotebookSummaryService()
|
||||
347
keep-notes/lib/ai/services/paragraph-refactor.service.ts
Normal file
347
keep-notes/lib/ai/services/paragraph-refactor.service.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Paragraph Refactor Service
|
||||
* Provides AI-powered text reformulation with 3 options:
|
||||
* 1. Clarify - Make ambiguous text clearer
|
||||
* 2. Shorten - Condense while keeping meaning
|
||||
* 3. Improve Style - Enhance readability and flow
|
||||
*/
|
||||
|
||||
import { LanguageDetectionService } from './language-detection.service'
|
||||
|
||||
export type RefactorMode = 'clarify' | 'shorten' | 'improveStyle'
|
||||
|
||||
export interface RefactorOption {
|
||||
mode: RefactorMode
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface RefactorResult {
|
||||
original: string
|
||||
refactored: string
|
||||
mode: RefactorMode
|
||||
language: string
|
||||
wordCountChange: {
|
||||
original: number
|
||||
refactored: number
|
||||
difference: number
|
||||
percentage: number
|
||||
}
|
||||
}
|
||||
|
||||
export const REFACTOR_OPTIONS: RefactorOption[] = [
|
||||
{
|
||||
mode: 'clarify',
|
||||
label: 'Clarify',
|
||||
description: 'Make the text clearer and easier to understand',
|
||||
icon: '💡'
|
||||
},
|
||||
{
|
||||
mode: 'shorten',
|
||||
label: 'Shorten',
|
||||
description: 'Condense the text while keeping key information',
|
||||
icon: '✂️'
|
||||
},
|
||||
{
|
||||
mode: 'improveStyle',
|
||||
label: 'Improve Style',
|
||||
description: 'Enhance readability, flow, and expression',
|
||||
icon: '✨'
|
||||
}
|
||||
]
|
||||
|
||||
export class ParagraphRefactorService {
|
||||
private languageDetection: LanguageDetectionService
|
||||
private readonly MIN_WORDS = 10
|
||||
private readonly MAX_WORDS = 500
|
||||
|
||||
constructor() {
|
||||
this.languageDetection = new LanguageDetectionService()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refactor a paragraph with the specified mode
|
||||
*/
|
||||
async refactor(
|
||||
content: string,
|
||||
mode: RefactorMode
|
||||
): Promise<RefactorResult> {
|
||||
// Validate word count
|
||||
const wordCount = content.split(/\s+/).length
|
||||
if (wordCount < this.MIN_WORDS || wordCount > this.MAX_WORDS) {
|
||||
throw new Error(
|
||||
`Please select ${this.MIN_WORDS}-${this.MAX_WORDS} words to reformulate`
|
||||
)
|
||||
}
|
||||
|
||||
// Detect language
|
||||
const { language } = await this.languageDetection.detectLanguage(content)
|
||||
|
||||
try {
|
||||
// Build prompts
|
||||
const systemPrompt = this.getSystemPrompt(mode)
|
||||
const userPrompt = this.getUserPrompt(mode, content, language)
|
||||
|
||||
// Get AI provider response using fetch
|
||||
let baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
// Remove /api suffix if present to avoid double /api/api/...
|
||||
if (baseUrl.endsWith('/api')) {
|
||||
baseUrl = baseUrl.slice(0, -4)
|
||||
}
|
||||
const modelName = process.env.OLLAMA_MODEL || 'granite4:latest'
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: modelName,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
stream: false,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Provider error: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
const refactored = this.extractRefactoredText(data.response)
|
||||
|
||||
// Calculate word count change
|
||||
const refactoredWordCount = refactored.split(/\s+/).length
|
||||
const wordCountChange = {
|
||||
original: wordCount,
|
||||
refactored: refactoredWordCount,
|
||||
difference: refactoredWordCount - wordCount,
|
||||
percentage: ((refactoredWordCount - wordCount) / wordCount) * 100
|
||||
}
|
||||
|
||||
return {
|
||||
original: content,
|
||||
refactored,
|
||||
mode,
|
||||
language,
|
||||
wordCountChange
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error('Failed to refactor paragraph. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all 3 refactor options for a paragraph at once
|
||||
* More efficient than calling refactor() 3 times
|
||||
*/
|
||||
async refactorAllModes(content: string): Promise<RefactorResult[]> {
|
||||
// Validate word count
|
||||
const wordCount = content.split(/\s+/).length
|
||||
if (wordCount < this.MIN_WORDS || wordCount > this.MAX_WORDS) {
|
||||
throw new Error(
|
||||
`Please select ${this.MIN_WORDS}-${this.MAX_WORDS} words to reformulate`
|
||||
)
|
||||
}
|
||||
|
||||
// Detect language
|
||||
const { language } = await this.languageDetection.detectLanguage(content)
|
||||
|
||||
try {
|
||||
// System prompt for all modes
|
||||
const systemPrompt = `You are an expert text editor who can improve text in multiple ways.
|
||||
Your task is to provide 3 different reformulations of the user's text.
|
||||
|
||||
For each reformulation:
|
||||
1. Clarify: Make the text clearer, more explicit, easier to understand
|
||||
2. Shorten: Condense the text while preserving all key information and meaning
|
||||
3. Improve Style: Enhance readability, flow, vocabulary, and expression
|
||||
|
||||
CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text.
|
||||
- If input is French, ALL 3 outputs MUST be in French
|
||||
- If input is German, ALL 3 outputs MUST be in German
|
||||
- If input is Spanish, ALL 3 outputs MUST be in Spanish
|
||||
- NEVER translate to English unless the input is in English
|
||||
|
||||
Maintain the original meaning and intent:
|
||||
- For "shorten", aim to reduce by 30-50% while keeping all key points
|
||||
- For "clarify", expand where necessary but keep it natural
|
||||
- For "improve style", keep similar length but enhance quality
|
||||
|
||||
Output Format (JSON):
|
||||
{
|
||||
"clarify": "clarified text here...",
|
||||
"shorten": "shortened text here...",
|
||||
"improveStyle": "improved text here..."
|
||||
}`
|
||||
|
||||
const userPrompt = `CRITICAL LANGUAGE INSTRUCTION: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English.
|
||||
|
||||
Please provide 3 reformulations of this ${language} text:
|
||||
|
||||
${content}
|
||||
|
||||
Original language: ${language}
|
||||
IMPORTANT: Provide all 3 versions in ${language}. No English, no explanations.`
|
||||
|
||||
// Get AI provider response using fetch
|
||||
let baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434'
|
||||
// Remove /api suffix if present to avoid double /api/api/...
|
||||
if (baseUrl.endsWith('/api')) {
|
||||
baseUrl = baseUrl.slice(0, -4)
|
||||
}
|
||||
const modelName = process.env.OLLAMA_MODEL || 'granite4:latest'
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: modelName,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
stream: false,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Provider error: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Parse JSON response
|
||||
const jsonResponse = JSON.parse(data.response)
|
||||
|
||||
const modes: RefactorMode[] = ['clarify', 'shorten', 'improveStyle']
|
||||
const results: RefactorResult[] = []
|
||||
|
||||
for (const mode of modes) {
|
||||
if (!jsonResponse[mode]) continue
|
||||
|
||||
const refactored = this.extractRefactoredText(jsonResponse[mode])
|
||||
const refactoredWordCount = refactored.split(/\s+/).length
|
||||
|
||||
results.push({
|
||||
original: content,
|
||||
refactored,
|
||||
mode,
|
||||
language,
|
||||
wordCountChange: {
|
||||
original: wordCount,
|
||||
refactored: refactoredWordCount,
|
||||
difference: refactoredWordCount - wordCount,
|
||||
percentage: ((refactoredWordCount - wordCount) / wordCount) * 100
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return results
|
||||
} catch (error) {
|
||||
throw new Error('Failed to generate refactor options. Please try again.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mode-specific system prompt
|
||||
*/
|
||||
private getSystemPrompt(mode: RefactorMode): string {
|
||||
const prompts = {
|
||||
clarify: `You are an expert at making text clearer and more understandable.
|
||||
Your goal: Rewrite the text to eliminate ambiguity, add necessary context, and improve clarity.
|
||||
|
||||
CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text. If input is French, output MUST be French. If input is German, output MUST be German. NEVER translate to English.
|
||||
Maintain the original meaning and tone, just make it clearer.`,
|
||||
|
||||
shorten: `You are an expert at concise writing.
|
||||
Your goal: Reduce the text length by 30-50% while preserving ALL key information and meaning.
|
||||
|
||||
CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text. If input is French, output MUST be French. If input is German, output MUST be German. NEVER translate to English.
|
||||
Remove fluff, repetition, and unnecessary words, but keep the substance.`,
|
||||
|
||||
improveStyle: `You are an expert editor with a focus on readability and flow.
|
||||
Your goal: Enhance the text's style, vocabulary, sentence structure, and overall quality.
|
||||
|
||||
CRITICAL LANGUAGE RULE: You MUST respond in the EXACT SAME LANGUAGE as the input text. If input is French, output MUST be French. If input is German, output MUST be German. NEVER translate to English.
|
||||
Maintain similar length but make it sound more professional and polished.`
|
||||
}
|
||||
|
||||
return prompts[mode]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get mode-specific user prompt
|
||||
*/
|
||||
private getUserPrompt(mode: RefactorMode, content: string, language: string): string {
|
||||
const instructions = {
|
||||
clarify: `IMPORTANT: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English.
|
||||
|
||||
Please clarify and make this ${language} text easier to understand:`,
|
||||
|
||||
shorten: `IMPORTANT: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English.
|
||||
|
||||
Please shorten this ${language} text while keeping all key information:`,
|
||||
|
||||
improveStyle: `IMPORTANT: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English.
|
||||
|
||||
Please improve the style and readability of this ${language} text:`
|
||||
}
|
||||
|
||||
return `${instructions[mode]}
|
||||
|
||||
${content}
|
||||
|
||||
CRITICAL: Respond ONLY with the refactored text in ${language}. No explanations, no meta-commentary, no English.`
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract refactored text from AI response
|
||||
* Handles JSON, markdown code blocks, or plain text
|
||||
*/
|
||||
private extractRefactoredText(response: string): string {
|
||||
// Try JSON first
|
||||
if (response.trim().startsWith('{')) {
|
||||
try {
|
||||
const parsed = JSON.parse(response)
|
||||
// Look for common response fields
|
||||
return parsed.refactored || parsed.text || parsed.result || response
|
||||
} catch {
|
||||
// Not valid JSON, continue
|
||||
}
|
||||
}
|
||||
|
||||
// Try markdown code block
|
||||
const codeBlockMatch = response.match(/```(?:markdown)?\n([\s\S]+?)\n```/)
|
||||
if (codeBlockMatch) {
|
||||
return codeBlockMatch[1].trim()
|
||||
}
|
||||
|
||||
// Fallback: trim whitespace and quotes
|
||||
return response.trim().replace(/^["']|["']$/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that text is within acceptable word count range
|
||||
*/
|
||||
validateWordCount(content: string): { valid: boolean; error?: string } {
|
||||
const wordCount = content.split(/\s+/).length
|
||||
|
||||
if (wordCount < this.MIN_WORDS) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Please select at least ${this.MIN_WORDS} words to reformulate (currently ${wordCount} words)`
|
||||
}
|
||||
}
|
||||
|
||||
if (wordCount > this.MAX_WORDS) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Please select at most ${this.MAX_WORDS} words to reformulate (currently ${wordCount} words)`
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const paragraphRefactorService = new ParagraphRefactorService()
|
||||
330
keep-notes/lib/ai/services/semantic-search.service.ts
Normal file
330
keep-notes/lib/ai/services/semantic-search.service.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* Semantic Search Service
|
||||
* Hybrid search combining keyword matching and semantic similarity
|
||||
* Uses Reciprocal Rank Fusion (RRF) for result ranking
|
||||
*/
|
||||
|
||||
import { embeddingService } from './embedding.service'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { auth } from '@/auth'
|
||||
|
||||
export interface SearchResult {
|
||||
noteId: string
|
||||
title: string | null
|
||||
content: string
|
||||
score: number
|
||||
matchType: 'exact' | 'related'
|
||||
language?: string | null
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
limit?: number
|
||||
threshold?: number // Minimum similarity score (0-1)
|
||||
includeExactMatches?: boolean
|
||||
notebookId?: string // NEW: Filter by notebook for contextual search (IA5)
|
||||
}
|
||||
|
||||
export class SemanticSearchService {
|
||||
private readonly RRF_K = 60 // RRF constant (default recommended value)
|
||||
private readonly DEFAULT_LIMIT = 20
|
||||
private readonly DEFAULT_THRESHOLD = 0.6
|
||||
|
||||
/**
|
||||
* Hybrid search: keyword + semantic with RRF fusion
|
||||
*/
|
||||
async search(
|
||||
query: string,
|
||||
options: SearchOptions = {}
|
||||
): Promise<SearchResult[]> {
|
||||
const {
|
||||
limit = this.DEFAULT_LIMIT,
|
||||
threshold = this.DEFAULT_THRESHOLD,
|
||||
includeExactMatches = true,
|
||||
notebookId // NEW: Contextual search within notebook (IA5)
|
||||
} = options
|
||||
|
||||
if (!query || query.trim().length < 2) {
|
||||
return []
|
||||
}
|
||||
|
||||
const session = await auth()
|
||||
const userId = session?.user?.id || null
|
||||
|
||||
try {
|
||||
// 1. Keyword search (SQLite FTS)
|
||||
const keywordResults = await this.keywordSearch(query, userId, notebookId)
|
||||
|
||||
// 2. Semantic search (vector similarity)
|
||||
const semanticResults = await this.semanticVectorSearch(query, userId, threshold, notebookId)
|
||||
|
||||
// 3. Reciprocal Rank Fusion
|
||||
const fusedResults = await this.reciprocalRankFusion(
|
||||
keywordResults,
|
||||
semanticResults
|
||||
)
|
||||
|
||||
// 4. Sort by final score and limit
|
||||
return fusedResults
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map(result => ({
|
||||
...result,
|
||||
matchType: result.score > 0.8 ? 'exact' : 'related'
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Error in hybrid search:', error)
|
||||
// Fallback to keyword-only search
|
||||
const keywordResults = await this.keywordSearch(query, userId)
|
||||
|
||||
// Fetch note details for keyword results
|
||||
const noteIds = keywordResults.slice(0, limit).map(r => r.noteId)
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { id: { in: noteIds } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
language: true
|
||||
}
|
||||
})
|
||||
|
||||
return notes.map(note => ({
|
||||
noteId: note.id,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
score: 1.0, // Default score for keyword-only results
|
||||
matchType: 'related' as const,
|
||||
language: note.language
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyword search using SQLite LIKE/FTS
|
||||
*/
|
||||
private async keywordSearch(
|
||||
query: string,
|
||||
userId: string | null,
|
||||
notebookId?: string // NEW: Filter by notebook (IA5)
|
||||
): Promise<Array<{ noteId: string; rank: number }>> {
|
||||
// Build query for case-insensitive search
|
||||
const searchPattern = `%${query}%`
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
...(userId ? { userId } : {}),
|
||||
...(notebookId !== undefined ? { notebookId } : {}), // NEW: Notebook filter
|
||||
OR: [
|
||||
{ title: { contains: query } },
|
||||
{ content: { contains: query } }
|
||||
]
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true
|
||||
}
|
||||
})
|
||||
|
||||
// Simple relevance scoring based on match position and frequency
|
||||
const results = notes.map(note => {
|
||||
const title = note.title || ''
|
||||
const content = note.content || ''
|
||||
const queryLower = query.toLowerCase()
|
||||
|
||||
// Count occurrences
|
||||
const titleMatches = (title.match(new RegExp(queryLower, 'gi')) || []).length
|
||||
const contentMatches = (content.match(new RegExp(queryLower, 'gi')) || []).length
|
||||
|
||||
// Boost title matches significantly
|
||||
const titlePosition = title.toLowerCase().indexOf(queryLower)
|
||||
const contentPosition = content.toLowerCase().indexOf(queryLower)
|
||||
|
||||
// Calculate rank (lower is better)
|
||||
let rank = 100
|
||||
|
||||
if (titleMatches > 0) {
|
||||
rank = titlePosition === 0 ? 1 : 10
|
||||
rank -= titleMatches * 2
|
||||
} else if (contentMatches > 0) {
|
||||
rank = contentPosition < 100 ? 20 : 30
|
||||
rank -= contentMatches
|
||||
}
|
||||
|
||||
return {
|
||||
noteId: note.id,
|
||||
rank
|
||||
}
|
||||
})
|
||||
|
||||
return results.sort((a, b) => a.rank - b.rank)
|
||||
}
|
||||
|
||||
/**
|
||||
* Semantic vector search using embeddings
|
||||
*/
|
||||
private async semanticVectorSearch(
|
||||
query: string,
|
||||
userId: string | null,
|
||||
threshold: number,
|
||||
notebookId?: string // NEW: Filter by notebook (IA5)
|
||||
): Promise<Array<{ noteId: string; rank: number }>> {
|
||||
try {
|
||||
// Generate query embedding
|
||||
const { embedding: queryEmbedding } = await embeddingService.generateEmbedding(query)
|
||||
|
||||
// Fetch all user's notes with embeddings
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
...(userId ? { userId } : {}),
|
||||
...(notebookId !== undefined ? { notebookId } : {}), // NEW: Notebook filter
|
||||
embedding: { not: null }
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
embedding: true
|
||||
}
|
||||
})
|
||||
|
||||
if (notes.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Calculate similarities for all notes
|
||||
const similarities = notes.map(note => {
|
||||
const noteEmbedding = embeddingService.deserialize(note.embedding || '[]')
|
||||
const similarity = embeddingService.calculateCosineSimilarity(
|
||||
queryEmbedding,
|
||||
noteEmbedding
|
||||
)
|
||||
|
||||
return {
|
||||
noteId: note.id,
|
||||
similarity
|
||||
}
|
||||
})
|
||||
|
||||
// Filter by threshold and convert to rank
|
||||
return similarities
|
||||
.filter(s => s.similarity >= threshold)
|
||||
.sort((a, b) => b.similarity - a.similarity)
|
||||
.map((s, index) => ({
|
||||
noteId: s.noteId,
|
||||
rank: index + 1 // 1-based rank
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Error in semantic vector search:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reciprocal Rank Fusion algorithm
|
||||
* Combines multiple ranked lists into a single ranking
|
||||
* Formula: RRF(score) = 1 / (k + rank)
|
||||
* k = 60 (default, prevents high rank from dominating)
|
||||
*/
|
||||
private async reciprocalRankFusion(
|
||||
keywordResults: Array<{ noteId: string; rank: number }>,
|
||||
semanticResults: Array<{ noteId: string; rank: number }>
|
||||
): Promise<SearchResult[]> {
|
||||
const scores = new Map<string, number>()
|
||||
|
||||
// Add keyword scores
|
||||
for (const result of keywordResults) {
|
||||
const rrfScore = 1 / (this.RRF_K + result.rank)
|
||||
scores.set(result.noteId, (scores.get(result.noteId) || 0) + rrfScore)
|
||||
}
|
||||
|
||||
// Add semantic scores
|
||||
for (const result of semanticResults) {
|
||||
const rrfScore = 1 / (this.RRF_K + result.rank)
|
||||
scores.set(result.noteId, (scores.get(result.noteId) || 0) + rrfScore)
|
||||
}
|
||||
|
||||
// Fetch note details
|
||||
const noteIds = Array.from(scores.keys())
|
||||
const notes = await prisma.note.findMany({
|
||||
where: { id: { in: noteIds } },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
content: true,
|
||||
language: true
|
||||
}
|
||||
})
|
||||
|
||||
// Combine scores with note details
|
||||
return notes.map(note => ({
|
||||
noteId: note.id,
|
||||
title: note.title,
|
||||
content: note.content,
|
||||
score: scores.get(note.id) || 0,
|
||||
matchType: 'related' as const,
|
||||
language: note.language
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate or update embedding for a note
|
||||
* Called when note is created or significantly updated
|
||||
*/
|
||||
async indexNote(noteId: string): Promise<void> {
|
||||
try {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: noteId },
|
||||
select: { content: true, embedding: true, lastAiAnalysis: true }
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new Error('Note not found')
|
||||
}
|
||||
|
||||
// Check if embedding needs regeneration
|
||||
const shouldRegenerate = embeddingService.shouldRegenerateEmbedding(
|
||||
note.content,
|
||||
note.embedding,
|
||||
note.lastAiAnalysis
|
||||
)
|
||||
|
||||
if (!shouldRegenerate) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new embedding
|
||||
const { embedding } = await embeddingService.generateEmbedding(note.content)
|
||||
|
||||
// Save to database
|
||||
await prisma.note.update({
|
||||
where: { id: noteId },
|
||||
data: {
|
||||
embedding: embeddingService.serialize(embedding),
|
||||
lastAiAnalysis: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error indexing note ${noteId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch index multiple notes (for initial migration or bulk updates)
|
||||
*/
|
||||
async indexBatchNotes(noteIds: string[]): Promise<void> {
|
||||
const BATCH_SIZE = 10 // Process in batches to avoid overwhelming
|
||||
|
||||
for (let i = 0; i < noteIds.length; i += BATCH_SIZE) {
|
||||
const batch = noteIds.slice(i, i + BATCH_SIZE)
|
||||
|
||||
await Promise.allSettled(
|
||||
batch.map(noteId => this.indexNote(noteId))
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const semanticSearchService = new SemanticSearchService()
|
||||
182
keep-notes/lib/ai/services/title-suggestion.service.ts
Normal file
182
keep-notes/lib/ai/services/title-suggestion.service.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Title Suggestion Service
|
||||
* Generates intelligent title suggestions based on note content
|
||||
*/
|
||||
|
||||
import { createOpenAI } from '@ai-sdk/openai'
|
||||
import { generateText } from 'ai'
|
||||
import { LanguageDetectionService } from './language-detection.service'
|
||||
|
||||
// Helper to get AI model for text generation
|
||||
function getTextGenerationModel() {
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
if (!apiKey) {
|
||||
throw new Error('OPENAI_API_KEY not configured for title generation')
|
||||
}
|
||||
|
||||
const openai = createOpenAI({ apiKey })
|
||||
return openai('gpt-4o-mini')
|
||||
}
|
||||
|
||||
export interface TitleSuggestion {
|
||||
title: string
|
||||
confidence: number // 0-100
|
||||
reasoning?: string // Why this title was suggested
|
||||
}
|
||||
|
||||
export class TitleSuggestionService {
|
||||
private languageDetection: LanguageDetectionService
|
||||
|
||||
constructor() {
|
||||
this.languageDetection = new LanguageDetectionService()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate 3 title suggestions for a note
|
||||
* Uses interface language (from user settings) for prompts
|
||||
*/
|
||||
async generateSuggestions(noteContent: string): Promise<TitleSuggestion[]> {
|
||||
// Detect language of the note content
|
||||
const { language: contentLanguage } = await this.languageDetection.detectLanguage(noteContent)
|
||||
|
||||
try {
|
||||
const model = getTextGenerationModel()
|
||||
|
||||
// System prompt - explains what to do
|
||||
const systemPrompt = `You are an expert title generator for a note-taking application.
|
||||
Your task is to generate 3 distinct, engaging titles that capture the essence of the user's note.
|
||||
|
||||
Requirements:
|
||||
- Generate EXACTLY 3 titles
|
||||
- Each title should be 3-8 words
|
||||
- Titles should be concise but descriptive
|
||||
- Each title should have a different style:
|
||||
1. Direct/Summary style - What the note is about
|
||||
2. Question style - Posing a question the note addresses
|
||||
3. Creative/Metaphorical style - Using imagery or analogy
|
||||
- Return titles in the SAME LANGUAGE as the user's note
|
||||
- Be helpful and avoid generic titles like "My Note" or "Untitled"
|
||||
|
||||
Output Format (JSON):
|
||||
{
|
||||
"suggestions": [
|
||||
{ "title": "...", "confidence": 85, "reasoning": "..." },
|
||||
{ "title": "...", "confidence": 80, "reasoning": "..." },
|
||||
{ "title": "...", "confidence": 75, "reasoning": "..." }
|
||||
]
|
||||
}`
|
||||
|
||||
// User prompt with language context
|
||||
const userPrompt = `Generate 3 title suggestions for this note:
|
||||
|
||||
${noteContent}
|
||||
|
||||
Note language detected: ${contentLanguage}
|
||||
Respond with titles in ${contentLanguage} (same language as the note).`
|
||||
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
temperature: 0.7
|
||||
})
|
||||
|
||||
// Parse JSON response
|
||||
const response = JSON.parse(text)
|
||||
|
||||
if (!response.suggestions || !Array.isArray(response.suggestions)) {
|
||||
throw new Error('Invalid response format')
|
||||
}
|
||||
|
||||
// Validate and limit to exactly 3 suggestions
|
||||
const suggestions = response.suggestions
|
||||
.slice(0, 3)
|
||||
.filter((s: any) => s.title && typeof s.title === 'string')
|
||||
.map((s: any) => ({
|
||||
title: s.title.trim(),
|
||||
confidence: Math.min(100, Math.max(0, s.confidence || 75)),
|
||||
reasoning: s.reasoning || ''
|
||||
}))
|
||||
|
||||
// Ensure we always return exactly 3 suggestions
|
||||
while (suggestions.length < 3) {
|
||||
suggestions.push({
|
||||
title: this.generateFallbackTitle(noteContent, contentLanguage),
|
||||
confidence: 60,
|
||||
reasoning: 'Generated fallback title'
|
||||
})
|
||||
}
|
||||
|
||||
return suggestions
|
||||
} catch (error) {
|
||||
console.error('Error generating title suggestions:', error)
|
||||
|
||||
// Fallback to simple extraction
|
||||
return this.generateFallbackSuggestions(noteContent, contentLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate fallback title from first few meaningful words
|
||||
*/
|
||||
private generateFallbackTitle(content: string, language: string): string {
|
||||
const words = content.split(/\s+/).filter(w => w.length > 3)
|
||||
|
||||
if (words.length === 0) {
|
||||
return language === 'fr' ? 'Note sans titre' : 'Untitled Note'
|
||||
}
|
||||
|
||||
// Take first 3-5 meaningful words
|
||||
const titleWords = words.slice(0, Math.min(5, words.length))
|
||||
return titleWords.join(' ').charAt(0).toUpperCase() + titleWords.join(' ').slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate fallback suggestions when AI fails
|
||||
*/
|
||||
private generateFallbackSuggestions(content: string, language: string): TitleSuggestion[] {
|
||||
const baseTitle = this.generateFallbackTitle(content, language)
|
||||
|
||||
return [
|
||||
{
|
||||
title: baseTitle,
|
||||
confidence: 70,
|
||||
reasoning: 'Extracted from note content'
|
||||
},
|
||||
{
|
||||
title: language === 'fr'
|
||||
? `Réflexions sur ${baseTitle.toLowerCase()}`
|
||||
: `Thoughts on ${baseTitle.toLowerCase()}`,
|
||||
confidence: 65,
|
||||
reasoning: 'Contextual variation'
|
||||
},
|
||||
{
|
||||
title: language === 'fr'
|
||||
? `${baseTitle}: Points clés`
|
||||
: `${baseTitle}: Key Points`,
|
||||
confidence: 60,
|
||||
reasoning: 'Summary style'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Save selected title to note metadata
|
||||
*/
|
||||
async recordFeedback(
|
||||
noteId: string,
|
||||
selectedTitle: string,
|
||||
allSuggestions: TitleSuggestion[]
|
||||
): Promise<void> {
|
||||
// This will be implemented in Phase 3 when we add feedback collection
|
||||
// For now, we just log it
|
||||
|
||||
// TODO: In Phase 3, save to AiFeedback table for:
|
||||
// - Improving future suggestions
|
||||
// - Building user preference model
|
||||
// - Computing confidence scores
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const titleSuggestionService = new TitleSuggestionService()
|
||||
@@ -3,6 +3,11 @@ export interface TagSuggestion {
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface TitleSuggestion {
|
||||
title: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface AIProvider {
|
||||
/**
|
||||
* Analyse le contenu et suggère des tags pertinents.
|
||||
@@ -13,6 +18,16 @@ export interface AIProvider {
|
||||
* Génère un vecteur d'embeddings pour la recherche sémantique.
|
||||
*/
|
||||
getEmbeddings(text: string): Promise<number[]>;
|
||||
|
||||
/**
|
||||
* Génère des suggestions de titres basées sur le contenu.
|
||||
*/
|
||||
generateTitles(prompt: string): Promise<TitleSuggestion[]>;
|
||||
|
||||
/**
|
||||
* Génère du texte basé sur un prompt.
|
||||
*/
|
||||
generateText(prompt: string): Promise<string>;
|
||||
}
|
||||
|
||||
export type AIProviderType = 'openai' | 'ollama';
|
||||
|
||||
80
keep-notes/lib/i18n/LanguageProvider.tsx
Normal file
80
keep-notes/lib/i18n/LanguageProvider.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { SupportedLanguage, loadTranslations, getTranslationValue, Translations } from './load-translations'
|
||||
|
||||
type LanguageContextType = {
|
||||
language: SupportedLanguage
|
||||
setLanguage: (lang: SupportedLanguage) => void
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
translations: Translations
|
||||
}
|
||||
|
||||
const LanguageContext = createContext<LanguageContextType | undefined>(undefined)
|
||||
|
||||
export function LanguageProvider({ children, initialLanguage = 'en' }: {
|
||||
children: ReactNode
|
||||
initialLanguage?: SupportedLanguage
|
||||
}) {
|
||||
const [language, setLanguageState] = useState<SupportedLanguage>(initialLanguage)
|
||||
const [translations, setTranslations] = useState<Translations | null>(null)
|
||||
|
||||
// Load translations when language changes
|
||||
useEffect(() => {
|
||||
const loadLang = async () => {
|
||||
const loaded = await loadTranslations(language)
|
||||
setTranslations(loaded)
|
||||
}
|
||||
loadLang()
|
||||
}, [language])
|
||||
|
||||
// Load saved language from localStorage on mount
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('user-language') as SupportedLanguage
|
||||
if (saved) {
|
||||
setLanguageState(saved)
|
||||
document.documentElement.lang = saved
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setLanguage = (lang: SupportedLanguage) => {
|
||||
setLanguageState(lang)
|
||||
localStorage.setItem('user-language', lang)
|
||||
// Update HTML lang attribute for font styling
|
||||
document.documentElement.lang = lang
|
||||
}
|
||||
|
||||
const t = (key: string, params?: Record<string, string | number>) => {
|
||||
if (!translations) return key
|
||||
|
||||
let value: any = getTranslationValue(translations, key)
|
||||
|
||||
// Replace parameters like {count}, {percentage}, etc.
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([param, paramValue]) => {
|
||||
value = value.replace(`{${param}}`, String(paramValue))
|
||||
})
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
if (!translations) {
|
||||
return null // Show loading state if needed
|
||||
}
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ language, setLanguage, t, translations }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
const context = useContext(LanguageContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useLanguage must be used within a LanguageProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
62
keep-notes/lib/i18n/detect-user-language.ts
Normal file
62
keep-notes/lib/i18n/detect-user-language.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Detect user's preferred language from their existing notes
|
||||
* Analyzes language distribution across all user's notes
|
||||
*/
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { SupportedLanguage } from './load-translations'
|
||||
|
||||
export async function detectUserLanguage(): Promise<SupportedLanguage> {
|
||||
const session = await auth()
|
||||
|
||||
// Default to English for non-logged-in users
|
||||
if (!session?.user?.id) {
|
||||
return 'en'
|
||||
}
|
||||
|
||||
try {
|
||||
// Get all user's notes with detected languages
|
||||
const notes = await prisma.note.findMany({
|
||||
where: {
|
||||
userId: session.user.id,
|
||||
language: { not: null }
|
||||
},
|
||||
select: {
|
||||
language: true,
|
||||
languageConfidence: true
|
||||
}
|
||||
})
|
||||
|
||||
if (notes.length === 0) {
|
||||
return 'en' // Default for new users
|
||||
}
|
||||
|
||||
// Count language occurrences weighted by confidence
|
||||
const languageScores: Record<string, number> = {}
|
||||
|
||||
for (const note of notes) {
|
||||
if (note.language) {
|
||||
const confidence = note.languageConfidence || 0.8
|
||||
languageScores[note.language] = (languageScores[note.language] || 0) + confidence
|
||||
}
|
||||
}
|
||||
|
||||
// Find language with highest score
|
||||
const sortedLanguages = Object.entries(languageScores)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
|
||||
if (sortedLanguages.length > 0) {
|
||||
const topLanguage = sortedLanguages[0][0] as SupportedLanguage
|
||||
// Verify it's a supported language
|
||||
if (['fr', 'en', 'es', 'de', 'fa'].includes(topLanguage)) {
|
||||
return topLanguage
|
||||
}
|
||||
}
|
||||
|
||||
return 'en'
|
||||
} catch (error) {
|
||||
console.error('Error detecting user language:', error)
|
||||
return 'en'
|
||||
}
|
||||
}
|
||||
8
keep-notes/lib/i18n/index.ts
Normal file
8
keep-notes/lib/i18n/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* i18n exports
|
||||
* Centralized internationalization system with JSON-based translations
|
||||
*/
|
||||
|
||||
export { LanguageProvider, useLanguage } from './LanguageProvider'
|
||||
export { detectUserLanguage } from './detect-user-language'
|
||||
export { loadTranslations, getTranslationValue, type SupportedLanguage, type Translations } from './load-translations'
|
||||
320
keep-notes/lib/i18n/load-translations.ts
Normal file
320
keep-notes/lib/i18n/load-translations.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* Load translations from JSON files
|
||||
*/
|
||||
|
||||
export type SupportedLanguage = 'en' | 'fr' | 'es' | 'de' | 'fa' | 'it' | 'pt' | 'ru' | 'zh' | 'ja' | 'ko' | 'ar' | 'hi' | 'nl' | 'pl'
|
||||
|
||||
export interface Translations {
|
||||
auth: {
|
||||
signIn: string
|
||||
signUp: string
|
||||
email: string
|
||||
password: string
|
||||
name: string
|
||||
emailPlaceholder: string
|
||||
passwordPlaceholder: string
|
||||
namePlaceholder: string
|
||||
passwordMinChars: string
|
||||
resetPassword: string
|
||||
resetPasswordInstructions: string
|
||||
forgotPassword: string
|
||||
noAccount: string
|
||||
hasAccount: string
|
||||
signInToAccount: string
|
||||
createAccount: string
|
||||
rememberMe: string
|
||||
orContinueWith: string
|
||||
}
|
||||
notes: {
|
||||
title: string
|
||||
newNote: string
|
||||
untitled: string
|
||||
placeholder: string
|
||||
markdownPlaceholder: string
|
||||
titlePlaceholder: string
|
||||
listItem: string
|
||||
addListItem: string
|
||||
newChecklist: string
|
||||
add: string
|
||||
adding: string
|
||||
close: string
|
||||
confirmDelete: string
|
||||
confirmLeaveShare: string
|
||||
sharedBy: string
|
||||
leaveShare: string
|
||||
delete: string
|
||||
archive: string
|
||||
unarchive: string
|
||||
pin: string
|
||||
unpin: string
|
||||
color: string
|
||||
changeColor: string
|
||||
setReminder: string
|
||||
setReminderButton: string
|
||||
date: string
|
||||
time: string
|
||||
reminderDateTimeRequired: string
|
||||
invalidDateTime: string
|
||||
reminderMustBeFuture: string
|
||||
reminderSet: string
|
||||
addImage: string
|
||||
addLink: string
|
||||
linkAdded: string
|
||||
linkMetadataFailed: string
|
||||
linkAddFailed: string
|
||||
invalidFileType: string
|
||||
fileTooLarge: string
|
||||
uploadFailed: string
|
||||
contentOrMediaRequired: string
|
||||
itemOrMediaRequired: string
|
||||
noteCreated: string
|
||||
noteCreateFailed: string
|
||||
aiAssistant: string
|
||||
changeSize: string
|
||||
backgroundOptions: string
|
||||
moreOptions: string
|
||||
remindMe: string
|
||||
markdownMode: string
|
||||
addCollaborators: string
|
||||
duplicate: string
|
||||
share: string
|
||||
showCollaborators: string
|
||||
pinned: string
|
||||
others: string
|
||||
noNotes: string
|
||||
noNotesFound: string
|
||||
createFirstNote: string
|
||||
}
|
||||
labels: {
|
||||
title: string
|
||||
filter: string
|
||||
manage: string
|
||||
manageTooltip: string
|
||||
changeColor: string
|
||||
changeColorTooltip: string
|
||||
delete: string
|
||||
deleteTooltip: string
|
||||
confirmDelete: string
|
||||
newLabelPlaceholder: string
|
||||
namePlaceholder: string
|
||||
addLabel: string
|
||||
createLabel: string
|
||||
labelName: string
|
||||
labelColor: string
|
||||
manageLabels: string
|
||||
clearAll: string
|
||||
filterByLabel: string
|
||||
tagAdded: string
|
||||
}
|
||||
search: {
|
||||
placeholder: string
|
||||
searchPlaceholder: string
|
||||
semanticInProgress: string
|
||||
semanticTooltip: string
|
||||
searching: string
|
||||
noResults: string
|
||||
resultsFound: string
|
||||
exactMatch: string
|
||||
related: string
|
||||
}
|
||||
collaboration: {
|
||||
emailPlaceholder: string
|
||||
addCollaborator: string
|
||||
removeCollaborator: string
|
||||
owner: string
|
||||
canEdit: string
|
||||
canView: string
|
||||
shareNote: string
|
||||
}
|
||||
ai: {
|
||||
analyzing: string
|
||||
clickToAddTag: string
|
||||
ignoreSuggestion: string
|
||||
generatingTitles: string
|
||||
generateTitlesTooltip: string
|
||||
poweredByAI: string
|
||||
languageDetected: string
|
||||
processing: string
|
||||
}
|
||||
titleSuggestions: {
|
||||
available: string
|
||||
title: string
|
||||
generating: string
|
||||
selectTitle: string
|
||||
dismiss: string
|
||||
}
|
||||
semanticSearch: {
|
||||
exactMatch: string
|
||||
related: string
|
||||
searching: string
|
||||
}
|
||||
paragraphRefactor: {
|
||||
title: string
|
||||
shorten: string
|
||||
expand: string
|
||||
improve: string
|
||||
formal: string
|
||||
casual: string
|
||||
}
|
||||
memoryEcho: {
|
||||
title: string
|
||||
description: string
|
||||
dailyInsight: string
|
||||
insightReady: string
|
||||
}
|
||||
nav: {
|
||||
home: string
|
||||
notes: string
|
||||
notebooks: string
|
||||
generalNotes: string
|
||||
archive: string
|
||||
settings: string
|
||||
profile: string
|
||||
aiSettings: string
|
||||
logout: string
|
||||
login: string
|
||||
}
|
||||
settings: {
|
||||
title: string
|
||||
description: string
|
||||
account: string
|
||||
appearance: string
|
||||
theme: string
|
||||
themeLight: string
|
||||
themeDark: string
|
||||
themeSystem: string
|
||||
notifications: string
|
||||
language: string
|
||||
selectLanguage: string
|
||||
privacy: string
|
||||
security: string
|
||||
about: string
|
||||
version: string
|
||||
settingsSaved: string
|
||||
settingsError: string
|
||||
}
|
||||
profile: {
|
||||
title: string
|
||||
description: string
|
||||
displayName: string
|
||||
email: string
|
||||
changePassword: string
|
||||
changePasswordDescription: string
|
||||
currentPassword: string
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
updatePassword: string
|
||||
passwordChangeSuccess: string
|
||||
passwordChangeFailed: string
|
||||
passwordUpdated: string
|
||||
passwordError: string
|
||||
languagePreferences: string
|
||||
languagePreferencesDescription: string
|
||||
preferredLanguage: string
|
||||
selectLanguage: string
|
||||
languageDescription: string
|
||||
autoDetect: string
|
||||
updateSuccess: string
|
||||
updateFailed: string
|
||||
languageUpdateSuccess: string
|
||||
languageUpdateFailed: string
|
||||
profileUpdated: string
|
||||
profileError: string
|
||||
accountSettings: string
|
||||
manageAISettings: string
|
||||
}
|
||||
aiSettings: {
|
||||
title: string
|
||||
description: string
|
||||
features: string
|
||||
provider: string
|
||||
providerAuto: string
|
||||
providerOllama: string
|
||||
providerOpenAI: string
|
||||
frequency: string
|
||||
frequencyDaily: string
|
||||
frequencyWeekly: string
|
||||
saving: string
|
||||
saved: string
|
||||
error: string
|
||||
}
|
||||
general: {
|
||||
loading: string
|
||||
save: string
|
||||
cancel: string
|
||||
add: string
|
||||
edit: string
|
||||
confirm: string
|
||||
close: string
|
||||
back: string
|
||||
next: string
|
||||
previous: string
|
||||
submit: string
|
||||
reset: string
|
||||
apply: string
|
||||
clear: string
|
||||
select: string
|
||||
tryAgain: string
|
||||
error: string
|
||||
operationSuccess: string
|
||||
operationFailed: string
|
||||
}
|
||||
colors: {
|
||||
default: string
|
||||
red: string
|
||||
blue: string
|
||||
green: string
|
||||
yellow: string
|
||||
purple: string
|
||||
pink: string
|
||||
orange: string
|
||||
gray: string
|
||||
}
|
||||
reminder: {
|
||||
title: string
|
||||
setReminder: string
|
||||
removeReminder: string
|
||||
reminderDate: string
|
||||
reminderTime: string
|
||||
save: string
|
||||
cancel: string
|
||||
}
|
||||
notebookSuggestion: {
|
||||
title: string
|
||||
description: string
|
||||
move: string
|
||||
dismiss: string
|
||||
dismissIn: string
|
||||
moveToNotebook: string
|
||||
generalNotes: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load translations from JSON files
|
||||
*/
|
||||
export async function loadTranslations(language: SupportedLanguage): Promise<Translations> {
|
||||
try {
|
||||
const translations = await import(`@/locales/${language}.json`)
|
||||
return translations.default as Translations
|
||||
} catch (error) {
|
||||
console.error(`Failed to load translations for ${language}:`, error)
|
||||
// Fallback to English
|
||||
const enTranslations = await import(`@/locales/en.json`)
|
||||
return enTranslations.default as Translations
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get nested translation value from object using dot notation
|
||||
*/
|
||||
export function getTranslationValue(translations: Translations, key: string): string {
|
||||
const keys = key.split('.')
|
||||
let value: any = translations
|
||||
|
||||
for (const k of keys) {
|
||||
value = value?.[k]
|
||||
}
|
||||
|
||||
return typeof value === 'string' ? value : key
|
||||
}
|
||||
@@ -17,6 +17,7 @@ declare const globalThis: {
|
||||
|
||||
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
|
||||
|
||||
export { prisma }
|
||||
export default prisma
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma
|
||||
|
||||
@@ -4,13 +4,36 @@ export interface CheckItem {
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notebook model for organizing notes
|
||||
*/
|
||||
export interface Notebook {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
color: string | null;
|
||||
order: number;
|
||||
userId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
// Relations
|
||||
notes?: Note[];
|
||||
labels?: Label[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Label model - contextual to notebooks
|
||||
*/
|
||||
export interface Label {
|
||||
id: string;
|
||||
name: string;
|
||||
color: LabelColorName;
|
||||
userId?: string | null;
|
||||
notebookId: string | null; // NEW: Belongs to a notebook
|
||||
userId?: string | null; // DEPRECATED: Kept for backward compatibility
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
// Relations
|
||||
notebook?: Notebook | null;
|
||||
}
|
||||
|
||||
export interface LinkMetadata {
|
||||
@@ -30,7 +53,7 @@ export interface Note {
|
||||
isArchived: boolean;
|
||||
type: 'text' | 'checklist';
|
||||
checkItems: CheckItem[] | null;
|
||||
labels: string[] | null;
|
||||
labels: string[] | null; // DEPRECATED: Array of label names stored as JSON string
|
||||
images: string[] | null;
|
||||
links: LinkMetadata[] | null;
|
||||
reminder: Date | null;
|
||||
@@ -44,6 +67,13 @@ export interface Note {
|
||||
embedding?: number[] | null;
|
||||
sharedWith?: string[];
|
||||
userId?: string | null;
|
||||
// NEW: Notebook relation (optional - null = "Notes générales" / Inbox)
|
||||
notebookId?: string | null;
|
||||
notebook?: Notebook | null;
|
||||
autoGenerated?: boolean | null;
|
||||
// Search result metadata (optional)
|
||||
matchType?: 'exact' | 'related' | null;
|
||||
searchScore?: number | null;
|
||||
}
|
||||
|
||||
export type NoteSize = 'small' | 'medium' | 'large';
|
||||
|
||||
Reference in New Issue
Block a user