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:
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()
|
||||
Reference in New Issue
Block a user