feat: improve related notes algorithm and add seed data

- Add multilingual stop words (English + Spanish) for better matching
- Add technical keywords set for relevance scoring
- Improve scoring weights: tags +3, title matches +3
- Fix false positives between unrelated notes
- Add README with usage instructions
- Add 47 seed examples for testing
- Update quick add shortcut behavior
- Add project summary

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-22 15:09:20 -03:00
parent 8b77c7b5df
commit cc4b2453b1
6 changed files with 524 additions and 26 deletions

View File

@@ -62,11 +62,18 @@ export function QuickAdd() {
// Focus on keyboard shortcut
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
if (e.key === 'n' && (e.metaKey || e.ctrlKey)) {
// Ctrl+N or Cmd+N to focus quick add
if ((e.key === 'n' && (e.metaKey || e.ctrlKey)) || (e.key === 'n' && e.altKey)) {
e.preventDefault()
inputRef.current?.focus()
inputRef.current?.select()
setIsExpanded(true)
}
// Escape to blur
if (e.key === 'Escape' && document.activeElement === inputRef.current) {
inputRef.current?.blur()
setIsExpanded(false)
}
}
window.addEventListener('keydown', handleGlobalKeyDown)
return () => window.removeEventListener('keydown', handleGlobalKeyDown)

View File

@@ -1,5 +1,81 @@
import { prisma } from '@/lib/prisma'
// 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
@@ -14,56 +90,59 @@ export async function getRelatedNotes(noteId: string, limit = 5): Promise<Scored
where: { id: noteId },
include: { tags: { include: { tag: true } } },
})
if (!note) return []
const noteTagNames = note.tags.map(t => t.tag.name)
const noteWords = note.title.toLowerCase().split(/\s+/).filter(w => w.length > 2)
const noteContentWords = note.content.toLowerCase().split(/\s+/).filter(w => w.length > 4)
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})`)
}
// +2 por cada tag compartido
// +3 por cada tag compartido
const sharedTags = noteTagNames.filter(t => other.tags.some(ot => ot.tag.name === t))
score += sharedTags.length * 2
score += sharedTags.length * 3
if (sharedTags.length > 0) {
reasons.push(`Shared tags: ${sharedTags.join(', ')}`)
reasons.push(`Tags: ${sharedTags.join(', ')}`)
}
// +1 por palabra relevante compartida en título
const sharedTitleWords = noteWords.filter(w =>
other.title.toLowerCase().includes(w)
// +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, 2) // max +2
score += Math.min(sharedTitleWords.length, 3) // max +3
if (sharedTitleWords.length > 0) {
reasons.push(`Title match: ${sharedTitleWords.slice(0, 2).join(', ')}`)
reasons.push(`Title: ${sharedTitleWords.slice(0, 2).join(', ')}`)
}
// +1 si keyword del contenido aparece en ambas
// +1 por palabra clave del contenido compartida
const otherContentWords = getSignificantWords(extractKeywords(other.content))
const sharedContentWords = noteContentWords.filter(w =>
other.content.toLowerCase().includes(w)
otherContentWords.includes(w)
)
score += Math.min(sharedContentWords.length, 2) // max +2
if (sharedContentWords.length > 0) {
reasons.push(`Content: ${sharedContentWords.slice(0, 2).join(', ')}`)
}
if (score > 0) {
// 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,
@@ -74,7 +153,7 @@ export async function getRelatedNotes(noteId: string, limit = 5): Promise<Scored
})
}
}
return scored
.sort((a, b) => b.score - a.score)
.slice(0, limit)