757 lines
23 KiB
TypeScript
757 lines
23 KiB
TypeScript
import { getChatProvider } from '../factory'
|
||
import { cosineSimilarity } from '@/lib/utils'
|
||
import { embeddingService } from './embedding.service'
|
||
import { getSystemConfig } from '@/lib/config'
|
||
import prisma from '@/lib/prisma'
|
||
import { Prisma } from '@prisma/client'
|
||
import { upsertNoteEmbedding } from '@/lib/embeddings'
|
||
import {
|
||
excerptPlainNoteContent,
|
||
prepareNoteTextForEmbedding,
|
||
} from '@/lib/text/plain-text'
|
||
import { detectTextDirection } from '@/lib/clip/rtl-content'
|
||
import {
|
||
SEMANTIC_SIMILARITY_FLOOR_CLIP,
|
||
SEMANTIC_SIMILARITY_FLOOR_DEMO,
|
||
SEMANTIC_SIMILARITY_FLOOR,
|
||
} from '@/lib/ai/semantic-proximity'
|
||
|
||
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 = SEMANTIC_SIMILARITY_FLOOR
|
||
private readonly SIMILARITY_THRESHOLD_DEMO = SEMANTIC_SIMILARITY_FLOOR_DEMO
|
||
private readonly SIMILARITY_THRESHOLD_CLIP = SEMANTIC_SIMILARITY_FLOOR_CLIP
|
||
private readonly MIN_DAYS_APART = 7 // Notes must be at least 7 days apart
|
||
private readonly MIN_DAYS_APART_CLIP = 0 // Notes clippées (sourceUrl) : même jour OK
|
||
private readonly MIN_DAYS_APART_DEMO = 0 // No delay for demo mode
|
||
private readonly MAX_INSIGHTS_PER_USER = 100 // Prevent spam
|
||
|
||
private isClippedNote(note: { sourceUrl?: string | null }): boolean {
|
||
return Boolean(note.sourceUrl?.trim())
|
||
}
|
||
|
||
private passesTimeDiversityFilter(
|
||
daysApart: number,
|
||
noteA: { sourceUrl?: string | null },
|
||
noteB: { sourceUrl?: string | null },
|
||
demoMode: boolean,
|
||
): boolean {
|
||
if (demoMode) return true
|
||
const minDays =
|
||
this.isClippedNote(noteA) || this.isClippedNote(noteB)
|
||
? this.MIN_DAYS_APART_CLIP
|
||
: this.MIN_DAYS_APART
|
||
return daysApart >= minDays
|
||
}
|
||
|
||
private isRtlOrClipNote(note: {
|
||
sourceUrl?: string | null
|
||
content?: string
|
||
title?: string | null
|
||
}): boolean {
|
||
if (this.isClippedNote(note)) return true
|
||
if (note.content?.includes('clip-article--rtl')) return true
|
||
const sample = prepareNoteTextForEmbedding(note.title, note.content || '')
|
||
return detectTextDirection(sample) === 'rtl'
|
||
}
|
||
|
||
private pairSimilarityThreshold(
|
||
noteA: { sourceUrl?: string | null; content?: string; title?: string | null },
|
||
noteB: { sourceUrl?: string | null; content?: string; title?: string | null },
|
||
demoMode: boolean,
|
||
): number {
|
||
if (demoMode) return this.SIMILARITY_THRESHOLD_DEMO
|
||
if (this.isRtlOrClipNote(noteA) || this.isRtlOrClipNote(noteB)) {
|
||
return this.SIMILARITY_THRESHOLD_CLIP
|
||
}
|
||
return this.SIMILARITY_THRESHOLD
|
||
}
|
||
|
||
/** Texte plain complet envoyé à l'API / résolution de blocs (pas de troncature). */
|
||
private connectionPlainText(
|
||
title: string | null,
|
||
content: string,
|
||
): string {
|
||
return prepareNoteTextForEmbedding(title, content)
|
||
}
|
||
|
||
private async upsertNoteEmbeddingFromNote(note: {
|
||
id: string
|
||
title: string | null
|
||
content: string
|
||
}): Promise<number[] | null> {
|
||
const text = prepareNoteTextForEmbedding(note.title, note.content)
|
||
if (!text.trim()) return null
|
||
try {
|
||
const { embedding } = await embeddingService.generateNoteEmbedding(note.title, note.content)
|
||
if (embedding?.length) {
|
||
await upsertNoteEmbedding(note.id, embedding)
|
||
return embedding
|
||
}
|
||
} catch (error) {
|
||
console.error(`[MemoryEcho] embedding failed for note ${note.id}:`, error)
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Generate embeddings for notes that don't have one yet
|
||
*/
|
||
private async ensureEmbeddings(userId: string): Promise<void> {
|
||
const notes = await prisma.note.findMany({
|
||
where: {
|
||
userId,
|
||
isArchived: false,
|
||
trashedAt: null,
|
||
},
|
||
select: {
|
||
id: true,
|
||
title: true,
|
||
content: true,
|
||
sourceUrl: true,
|
||
noteEmbedding: { select: { noteId: true } },
|
||
},
|
||
})
|
||
|
||
if (notes.length === 0) return
|
||
|
||
for (const note of notes) {
|
||
if (!note.content?.trim()) continue
|
||
const isClip = this.isClippedNote(note)
|
||
const missing = !note.noteEmbedding
|
||
if (!missing && !isClip) continue
|
||
await this.upsertNoteEmbeddingFromNote(note)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Find meaningful connections between user's notes
|
||
*/
|
||
async findConnections(userId: string, demoMode: boolean = false): Promise<NoteConnection[]> {
|
||
// GDPR AI Consent check — compliance skip if not granted (AC6)
|
||
const userSettings = await prisma.userAISettings.findUnique({
|
||
where: { userId },
|
||
select: { aiProcessingConsent: true },
|
||
})
|
||
if (!userSettings?.aiProcessingConsent) {
|
||
console.log(`[MemoryEchoService] User ${userId} has not given AI consent. Skipping connection generation for compliance.`)
|
||
return []
|
||
}
|
||
|
||
// Ensure all notes have embeddings before searching for connections
|
||
await this.ensureEmbeddings(userId)
|
||
|
||
// Get all user's notes with embeddings
|
||
const notes = await prisma.note.findMany({
|
||
where: {
|
||
userId,
|
||
isArchived: false,
|
||
trashedAt: null,
|
||
// noteEmbedding: { isNot: null } // Removed because Unsupported type cannot be used in 'where' easily
|
||
},
|
||
select: {
|
||
id: true,
|
||
title: true,
|
||
content: true,
|
||
sourceUrl: true,
|
||
noteEmbedding: true,
|
||
createdAt: true
|
||
},
|
||
orderBy: { createdAt: 'desc' }
|
||
})
|
||
|
||
if (notes.length < 2) {
|
||
return [] // Need at least 2 notes to find connections
|
||
}
|
||
|
||
if (notes.length < 2) return []
|
||
|
||
// Fetch embeddings separately using raw SQL to avoid deserialization error
|
||
const noteIds = notes.map(n => n.id)
|
||
const embeddings = noteIds.length === 0 ? [] : await prisma.$queryRaw<Array<{ noteId: string, embedding: any }>>(
|
||
Prisma.sql`SELECT "noteId", "embedding"::text FROM "NoteEmbedding" WHERE "noteId" IN (${Prisma.join(noteIds)})`
|
||
)
|
||
const embeddingMap = new Map(embeddings.map(e => [e.noteId, e.embedding]))
|
||
|
||
const notesWithEmbeddings = notes
|
||
.map(note => ({
|
||
...note,
|
||
embedding: embeddingMap.has(note.id)
|
||
? embeddingService.fromVectorString(embeddingMap.get(note.id))
|
||
: null
|
||
}))
|
||
.filter(note => note.embedding && Array.isArray(note.embedding))
|
||
|
||
const connections: NoteConnection[] = []
|
||
|
||
// Load user feedback to adjust thresholds per note
|
||
const feedbackInsights = await prisma.memoryEchoInsight.findMany({
|
||
where: { userId, feedback: { not: null } },
|
||
select: { note1Id: true, note2Id: true, feedback: true }
|
||
})
|
||
|
||
const notePenalty = new Map<string, number>() // positive = higher threshold (penalty), negative = lower (boost)
|
||
for (const fi of feedbackInsights) {
|
||
if (fi.feedback === 'thumbs_down') {
|
||
notePenalty.set(fi.note1Id, (notePenalty.get(fi.note1Id) || 0) + 0.15)
|
||
notePenalty.set(fi.note2Id, (notePenalty.get(fi.note2Id) || 0) + 0.15)
|
||
} else if (fi.feedback === 'thumbs_up') {
|
||
notePenalty.set(fi.note1Id, (notePenalty.get(fi.note1Id) || 0) - 0.05)
|
||
notePenalty.set(fi.note2Id, (notePenalty.get(fi.note2Id) || 0) - 0.05)
|
||
}
|
||
}
|
||
|
||
// 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 (sauf clips récents)
|
||
if (!this.passesTimeDiversityFilter(daysApart, note1, note2, demoMode)) {
|
||
continue
|
||
}
|
||
|
||
// Calculate cosine similarity
|
||
const similarity = cosineSimilarity(note1.embedding!, note2.embedding!)
|
||
|
||
// Similarity threshold for meaningful connections (adjusted by feedback)
|
||
const baseThreshold = this.pairSimilarityThreshold(note1, note2, demoMode)
|
||
const adjustedThreshold = baseThreshold
|
||
+ (notePenalty.get(note1.id) || 0)
|
||
+ (notePenalty.get(note2.id) || 0)
|
||
if (similarity >= adjustedThreshold) {
|
||
connections.push({
|
||
note1: {
|
||
id: note1.id,
|
||
title: note1.title,
|
||
content: this.connectionPlainText(note1.title, note1.content),
|
||
createdAt: note1.createdAt
|
||
},
|
||
note2: {
|
||
id: note2.id,
|
||
title: note2.title,
|
||
content: this.connectionPlainText(note2.title, note2.content || ''),
|
||
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 getSystemConfig()
|
||
const provider = getChatProvider(config)
|
||
|
||
const note1Desc = note1Title || 'Untitled note'
|
||
const note2Desc = note2Title || 'Untitled note'
|
||
const excerpt1 = excerptPlainNoteContent(note1Title, note1Content, 1200)
|
||
const excerpt2 = excerptPlainNoteContent(note2Title, note2Content, 1200)
|
||
const directionSample = `${note1Desc}\n${excerpt1}\n${note2Desc}\n${excerpt2}`
|
||
const isRtl = detectTextDirection(directionSample) === 'rtl'
|
||
|
||
const prompt = isRtl
|
||
? `تو یک دستیار هستی که ارتباط بین یادداشتها را تحلیل میکنی.
|
||
|
||
یادداشت ۱: «${note1Desc}»
|
||
متن: ${excerpt1}
|
||
|
||
یادداشت ۲: «${note2Desc}»
|
||
متن: ${excerpt2}
|
||
|
||
در یک جمله کوتاه (حداکثر ۱۵ کلمه) به فارسی توضیح بده چرا این دو یادداشت به هم مرتبطاند. فقط رابطه معنایی را بگو.`
|
||
: `You are a helpful assistant analyzing connections between notes.
|
||
|
||
Note 1: "${note1Desc}"
|
||
Content: ${excerpt1}
|
||
|
||
Note 2: "${note2Desc}"
|
||
Content: ${excerpt2}
|
||
|
||
Explain in one brief sentence (max 15 words) why these notes are connected. Focus on the semantic relationship.`
|
||
|
||
const response = await provider.generateText(prompt)
|
||
|
||
const insight = response
|
||
.replace(/^["'«»]|["'«»]$/g, '')
|
||
.replace(/^[^.]+\.\s*/, '')
|
||
.trim()
|
||
.substring(0, 150)
|
||
|
||
const fallback = isRtl
|
||
? 'این یادداشتها از نظر معنایی به هم مرتبط به نظر میرسند.'
|
||
: 'These notes appear to be semantically related.'
|
||
|
||
return insight || fallback
|
||
|
||
} catch (error) {
|
||
console.error('[MemoryEcho] Failed to generate insight:', error)
|
||
const sample = excerptPlainNoteContent(note1Title, note1Content, 200)
|
||
+ excerptPlainNoteContent(note2Title, note2Content, 200)
|
||
if (detectTextDirection(sample) === 'rtl') {
|
||
return 'این یادداشتها از نظر معنایی به هم مرتبط به نظر میرسند.'
|
||
}
|
||
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
|
||
// In demo mode, add milliseconds offset to avoid @@unique([userId, insightDate]) collision
|
||
const insightDateValue = demoMode
|
||
? new Date(Date.now() + Math.floor(Math.random() * 1000))
|
||
: new Date()
|
||
|
||
const insight = await prisma.memoryEchoInsight.create({
|
||
data: {
|
||
userId,
|
||
note1Id: newConnection.note1.id,
|
||
note2Id: newConnection.note2.id,
|
||
similarityScore: newConnection.similarityScore,
|
||
insight: insightText,
|
||
insightDate: insightDateValue,
|
||
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: {
|
||
timestamp: new Date().toISOString()
|
||
} as any
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get all connections for a specific note
|
||
*/
|
||
async getConnectionsForNote(noteId: string, userId: string): Promise<NoteConnection[]> {
|
||
// Ensure all notes have embeddings before searching
|
||
await this.ensureEmbeddings(userId)
|
||
|
||
// Get the note with embedding
|
||
const targetNote = await prisma.note.findUnique({
|
||
where: { id: noteId },
|
||
select: {
|
||
id: true,
|
||
title: true,
|
||
content: true,
|
||
sourceUrl: true,
|
||
createdAt: true,
|
||
userId: true
|
||
}
|
||
})
|
||
|
||
if (!targetNote || targetNote.userId !== userId) {
|
||
return [] // Note not found or doesn't belong to user
|
||
}
|
||
|
||
// Fetch embedding separately
|
||
const embeddingResult: Array<{ embedding: any }> = await prisma.$queryRawUnsafe(
|
||
`SELECT "embedding"::text FROM "NoteEmbedding" WHERE "noteId" = $1`,
|
||
noteId
|
||
)
|
||
const targetEmbeddingStr = embeddingResult[0]?.embedding
|
||
|
||
let targetEmbedding = targetEmbeddingStr
|
||
? embeddingService.fromVectorString(targetEmbeddingStr)
|
||
: null
|
||
|
||
if (!targetEmbedding && targetNote.content?.trim()) {
|
||
targetEmbedding = await this.upsertNoteEmbeddingFromNote(targetNote)
|
||
}
|
||
|
||
if (!targetEmbedding) {
|
||
return []
|
||
}
|
||
|
||
// 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 },
|
||
isArchived: false,
|
||
trashedAt: null,
|
||
},
|
||
select: {
|
||
id: true,
|
||
title: true,
|
||
content: true,
|
||
sourceUrl: true,
|
||
createdAt: true
|
||
},
|
||
orderBy: { createdAt: 'desc' }
|
||
})
|
||
|
||
if (otherNotes.length === 0) {
|
||
return []
|
||
}
|
||
|
||
// Fetch all other embeddings
|
||
const otherNoteIds = otherNotes.map(n => n.id)
|
||
const otherEmbeddings = otherNoteIds.length === 0 ? [] : await prisma.$queryRaw<Array<{ noteId: string, embedding: any }>>(
|
||
Prisma.sql`SELECT "noteId", "embedding"::text FROM "NoteEmbedding" WHERE "noteId" IN (${Prisma.join(otherNoteIds)})`
|
||
)
|
||
const otherEmbeddingMap = new Map(otherEmbeddings.map(e => [e.noteId, e.embedding]))
|
||
|
||
// Check if user has demo mode enabled
|
||
const settings = await prisma.userAISettings.findUnique({
|
||
where: { userId }
|
||
})
|
||
const demoMode = settings?.demoMode || false
|
||
|
||
// Load user feedback to adjust thresholds
|
||
const feedbackInsights = await prisma.memoryEchoInsight.findMany({
|
||
where: { userId, feedback: { not: null } },
|
||
select: { note1Id: true, note2Id: true, feedback: true }
|
||
})
|
||
const notePenalty = new Map<string, number>()
|
||
for (const fi of feedbackInsights) {
|
||
if (fi.feedback === 'thumbs_down') {
|
||
notePenalty.set(fi.note1Id, (notePenalty.get(fi.note1Id) || 0) + 0.15)
|
||
notePenalty.set(fi.note2Id, (notePenalty.get(fi.note2Id) || 0) + 0.15)
|
||
} else if (fi.feedback === 'thumbs_up') {
|
||
notePenalty.set(fi.note1Id, (notePenalty.get(fi.note1Id) || 0) - 0.05)
|
||
notePenalty.set(fi.note2Id, (notePenalty.get(fi.note2Id) || 0) - 0.05)
|
||
}
|
||
}
|
||
|
||
const connections: NoteConnection[] = []
|
||
|
||
// Compare target note with all other notes
|
||
for (const otherNote of otherNotes) {
|
||
const otherEmbeddingStr = otherEmbeddingMap.get(otherNote.id)
|
||
let otherEmbedding = otherEmbeddingStr
|
||
? embeddingService.fromVectorString(otherEmbeddingStr)
|
||
: null
|
||
|
||
if (!otherEmbedding && otherNote.content?.trim()) {
|
||
otherEmbedding = await this.upsertNoteEmbeddingFromNote(otherNote)
|
||
}
|
||
if (!otherEmbedding) continue
|
||
|
||
// 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 (clips récents autorisés sans délai de 7 jours)
|
||
if (!this.passesTimeDiversityFilter(daysApart, targetNote, otherNote, demoMode)) {
|
||
continue
|
||
}
|
||
|
||
// Calculate cosine similarity
|
||
const similarity = cosineSimilarity(targetEmbedding, otherEmbedding)
|
||
|
||
// Similarity threshold (adjusted by feedback)
|
||
const baseThreshold = this.pairSimilarityThreshold(targetNote, otherNote, demoMode)
|
||
const adjustedThreshold = baseThreshold
|
||
+ (notePenalty.get(targetNote.id) || 0)
|
||
+ (notePenalty.get(otherNote.id) || 0)
|
||
if (similarity >= adjustedThreshold) {
|
||
connections.push({
|
||
note1: {
|
||
id: targetNote.id,
|
||
title: targetNote.title,
|
||
content: this.connectionPlainText(targetNote.title, targetNote.content),
|
||
createdAt: targetNote.createdAt
|
||
},
|
||
note2: {
|
||
id: otherNote.id,
|
||
title: otherNote.title,
|
||
content: this.connectionPlainText(otherNote.title, otherNote.content || ''),
|
||
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()
|