Files
recall/src/lib/related.ts
Daniel Arroyo 05b8f3910d feat: MVP-3 Sprint 1 - Usage tracking, smart dashboard, scoring boost
## Registro de Uso
- Nuevo modelo NoteUsage en Prisma
- Tipos de eventos: view, search_click, related_click, link_click, copy_command, copy_snippet
- Funciones: trackNoteUsage, getUsageStats, getRecentlyUsedNotes
- localStorage: recentlyViewed (últimas 10 notas)
- Rastreo de copias en markdown-content.tsx

## Dashboard Rediseñado
- 5 bloques: Recientes, Más usadas, Comandos recientes, Snippets recientes, Según actividad
- Nuevo src/lib/dashboard.ts con getDashboardData()
- Recomendaciones basadas en recentlyViewed

## Scoring con Uso Real
- search.ts: +1 per 5 views (max +3), +2 recency boost
- related.ts: mismo sistema de usage boost
- No eclipsa match textual fuerte

## Tests
- 110 tests pasando (usage, dashboard, related, search)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-22 16:03:14 -03:00

172 lines
7.3 KiB
TypeScript

import { prisma } from '@/lib/prisma'
import { getUsageStats } from '@/lib/usage'
// Stop words to filter out from content matching (English + Spanish)
const STOP_WORDS = new Set([
// English
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
'of', 'with', 'by', 'from', 'as', 'is', 'was', 'are', 'were', 'been',
'be', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
'should', 'may', 'might', 'must', 'shall', 'can', 'need', 'dare', 'ought',
'used', 'it', 'its', 'this', 'that', 'these', 'those', 'i', 'you', 'he',
'she', 'we', 'they', 'what', 'which', 'who', 'whom', 'whose', 'where',
'when', 'why', 'how', 'all', 'each', 'every', 'both', 'few', 'more',
'most', 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own',
'same', 'so', 'than', 'too', 'very', 'just', 'also', 'now', 'here',
'there', 'then', 'once', 'if', 'your', 'our', 'their', 'my', 'his',
'her', 'into', 'over', 'under', 'after', 'before', 'between', 'through',
'during', 'above', 'below', 'up', 'down', 'out', 'off', 'about', 'against',
'config', 'file', 'files', 'using', 'use', 'example', 'following', 'etc',
'based', 'include', 'includes', 'included', 'add', 'added', 'adding',
'see', 'want', 'make', 'made', 'creating', 'create', 'created',
// Spanish
'el', 'la', 'los', 'las', 'un', 'una', 'unos', 'unas', 'y', 'o', 'pero',
'en', 'de', 'a', 'con', 'por', 'para', 'sin', 'sobre', 'entre', 'del',
'al', 'lo', 'se', 'es', 'son', 'era', 'eran', 'fue', 'fueron', 'ser',
'estar', 'está', 'están', 'estaba', 'estaban', 'he', 'ha', 'han', 'hay',
'haber', 'había', 'habían', 'tener', 'tiene', 'tienen', 'tenía', 'hacer',
'hace', 'hacen', 'hizo', 'hicieron', 'poder', 'puede', 'pueden', 'podía',
'este', 'esta', 'estos', 'estas', 'ese', 'esa', 'esos', 'esas', 'esto',
'eso', 'cual', 'cuales', 'quien', 'quienes', 'cuyo', 'cuyos', 'donde',
'cuando', 'como', 'porque', 'ya', 'aun', 'aunque', 'si', 'no', 'ni',
'mi', 'tu', 'su', 'sus', 'nuestro', 'nuestra', 'nuestros', 'nuestras',
'yo', 'tú', 'él', 'ella', 'ellos', 'ellas', 'nosotros', 'vosotros',
'ustedes', 'mí', 'ti', 'sí', 'qué', 'quién', 'cuál', 'cuáles',
'cuánto', 'cuántos', 'cuánta', 'cuántas', 'dónde', 'adónde', 'de dónde',
'nada', 'nadie', 'algo', 'alguien', 'todo', 'todos', 'toda', 'todas',
'cada', 'otro', 'otra', 'otros', 'otras', 'mismo', 'misma', 'mismos',
'mismas', 'tanto', 'tanta', 'tantos', 'tantas', 'bastante', 'bastantes',
'muy', 'más', 'menos', 'mejor', 'peor', 'mucho', 'poco', 'casi', 'solo',
'solamente', 'también', 'además', 'entonces', 'ahora', 'hoy', 'aquí',
'allí', 'así', 'así', 'tan', 'qué', 'quién', 'cuál', 'ver', 'vez',
'parte', 'parte', 'manera', 'forma', 'caso', 'casos', 'momento', 'lugar',
'día', 'días', 'año', 'años', 'mes', 'meses', 'semana', 'semanas',
'hora', 'horas', 'minuto', 'minutos', 'segundo', 'segundos',
// Common tech words that cause false positives
'command', 'comando', 'description', 'descripción', 'description', 'nota',
'notes', 'notas', 'content', 'contenido', 'code', 'código', 'ejemplo',
'example', 'steps', 'pasos', 'item', 'items', 'quantity', 'cantidad',
'añadir', 'agregar', 'nuevo', 'nueva', 'nuevos', 'nuevas', 'nueces',
])
// Keywords that indicate actual relevance (technical terms)
const KEYWORDS = new Set([
'git', 'docker', 'react', 'typescript', 'javascript', 'python', 'sql',
'postgres', 'postgresql', 'mysql', 'redis', 'nginx', 'kubernetes', 'k8s',
'api', 'http', 'json', 'xml', 'html', 'css', 'node', 'nodejs', 'npm',
'bash', 'shell', 'linux', 'ubuntu', 'aws', 'gcp', 'azure', 'vercel',
'prisma', 'nextjs', 'next', 'tailwind', 'eslint', 'prettier', 'jest',
'database', 'db', 'server', 'client', 'frontend', 'backend', 'fullstack',
'crud', 'rest', 'graphql', 'websocket', 'ssh', 'ssl', 'tls', 'jwt',
'auth', 'authentication', 'authorization', 'cookie', 'session', 'cache',
'deploy', 'deployment', 'ci', 'cd', 'pipeline', 'docker-compose',
'container', 'image', 'build', 'test', 'production', 'staging', 'dev',
'development', 'development', 'environment', 'config', 'configuration',
'variable', 'env', 'secret', 'key', 'password', 'token',
])
function extractKeywords(text: string): string[] {
const words = text.toLowerCase()
.split(/[\s\-_.,;:!?()\[\]{}'"]+/)
.filter(w => w.length > 2)
return words.filter(w => !STOP_WORDS.has(w))
}
function getSignificantWords(words: string[]): string[] {
return words.filter(w => KEYWORDS.has(w) || w.length > 4)
}
interface ScoredNote {
id: string
title: string
type: string
tags: string[]
score: number
reason: string
}
export async function getRelatedNotes(noteId: string, limit = 5): Promise<ScoredNote[]> {
const note = await prisma.note.findUnique({
where: { id: noteId },
include: { tags: { include: { tag: true } } },
})
if (!note) return []
const noteTagNames = note.tags.map(t => t.tag.name)
const noteTitleWords = getSignificantWords(extractKeywords(note.title))
const noteContentWords = getSignificantWords(extractKeywords(note.content))
const allNotes = await prisma.note.findMany({
where: { id: { not: noteId } },
include: { tags: { include: { tag: true } } },
})
const scored: ScoredNote[] = []
for (const other of allNotes) {
let score = 0
const reasons: string[] = []
// +3 si comparten tipo
if (other.type === note.type) {
score += 3
reasons.push(`Same type (${note.type})`)
}
// +3 por cada tag compartido
const sharedTags = noteTagNames.filter(t => other.tags.some(ot => ot.tag.name === t))
score += sharedTags.length * 3
if (sharedTags.length > 0) {
reasons.push(`Tags: ${sharedTags.join(', ')}`)
}
// +2 por palabra clave del título compartida
const otherTitleWords = extractKeywords(other.title)
const sharedTitleWords = noteTitleWords.filter(w =>
otherTitleWords.includes(w)
)
score += Math.min(sharedTitleWords.length, 3) // max +3
if (sharedTitleWords.length > 0) {
reasons.push(`Title: ${sharedTitleWords.slice(0, 2).join(', ')}`)
}
// +1 por palabra clave del contenido compartida
const otherContentWords = getSignificantWords(extractKeywords(other.content))
const sharedContentWords = noteContentWords.filter(w =>
otherContentWords.includes(w)
)
score += Math.min(sharedContentWords.length, 2) // max +2
if (sharedContentWords.length > 0) {
reasons.push(`Content: ${sharedContentWords.slice(0, 2).join(', ')}`)
}
// Usage-based boost (small, does not eclipse content matching)
// +1 per 5 views (max +3), +2 if used recently (recency)
const usageStats = await getUsageStats(other.id, 7) // last 7 days for recency
const viewBoost = Math.min(Math.floor(usageStats.views / 5), 3)
score += viewBoost
// Recency: if used in last 7 days, add +2
if (usageStats.views >= 1 || usageStats.relatedClicks >= 1) {
score += 2
}
// Solo incluir si tiene score > 0 Y al menos una razón válida
if (score > 0 && reasons.length > 0) {
scored.push({
id: other.id,
title: other.title,
type: other.type,
tags: other.tags.map(t => t.tag.name),
score,
reason: reasons.join(' | '),
})
}
}
return scored
.sort((a, b) => b.score - a.score)
.slice(0, limit)
}