feat: MVP-3 Sprint 4 - Co-usage, metrics, centrality, creation source, feature flags
- Add NoteCoUsage model and co-usage tracking when viewing notes - Add creationSource field to notes (form/quick/import) - Add dashboard metrics API (/api/metrics) - Add centrality calculation (/api/centrality) - Add feature flags system for toggling features - Add multiline QuickAdd with smart paste type detection - Add internal link suggestions while editing notes - Add type inference for automatic note type detection - Add comprehensive tests for type-inference and link-suggestions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
89
src/lib/centrality.ts
Normal file
89
src/lib/centrality.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export interface CentralNote {
|
||||
id: string
|
||||
title: string
|
||||
type: string
|
||||
centralityScore: number
|
||||
backlinks: number
|
||||
outboundLinks: number
|
||||
usageViews: number
|
||||
coUsageWeight: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate centrality score for all notes.
|
||||
* A note is "central" if it has many connections (backlinks/outbound) and high usage.
|
||||
*/
|
||||
export async function getCentralNotes(limit = 10): Promise<CentralNote[]> {
|
||||
try {
|
||||
// Get all notes with their counts
|
||||
const notes = await prisma.note.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
backlinks: { select: { id: true } },
|
||||
outbound: { select: { id: true } },
|
||||
},
|
||||
})
|
||||
|
||||
// Get usage stats for all notes
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
|
||||
const usageStats = await prisma.noteUsage.groupBy({
|
||||
by: ['noteId'],
|
||||
where: {
|
||||
eventType: 'view',
|
||||
createdAt: { gte: thirtyDaysAgo },
|
||||
},
|
||||
_count: { id: true },
|
||||
})
|
||||
const usageMap = new Map(usageStats.map((u) => [u.noteId, u._count.id]))
|
||||
|
||||
// Get co-usage stats
|
||||
const coUsageStats = await prisma.noteCoUsage.groupBy({
|
||||
by: ['fromNoteId', 'toNoteId'],
|
||||
_sum: { weight: true },
|
||||
})
|
||||
const coUsageMap = new Map<string, number>()
|
||||
for (const cu of coUsageStats) {
|
||||
coUsageMap.set(cu.fromNoteId, (coUsageMap.get(cu.fromNoteId) || 0) + (cu._sum.weight || 0))
|
||||
coUsageMap.set(cu.toNoteId, (coUsageMap.get(cu.toNoteId) || 0) + (cu._sum.weight || 0))
|
||||
}
|
||||
|
||||
// Calculate centrality score for each note
|
||||
const scoredNotes: CentralNote[] = notes.map((note) => {
|
||||
const backlinks = note.backlinks.length
|
||||
const outboundLinks = note.outbound.length
|
||||
const usageViews = usageMap.get(note.id) || 0
|
||||
const coUsageWeight = coUsageMap.get(note.id) || 0
|
||||
|
||||
// Centrality formula:
|
||||
// - Each backlink = 3 points (incoming connections show importance)
|
||||
// - Each outbound link = 1 point (shows knowledge breadth)
|
||||
// - Each usage view = 0.5 points (shows relevance)
|
||||
// - Each co-usage weight = 2 points (shows related usage patterns)
|
||||
const centralityScore =
|
||||
backlinks * 3 + outboundLinks * 1 + usageViews * 0.5 + coUsageWeight * 2
|
||||
|
||||
return {
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
type: note.type,
|
||||
centralityScore,
|
||||
backlinks,
|
||||
outboundLinks,
|
||||
usageViews,
|
||||
coUsageWeight,
|
||||
}
|
||||
})
|
||||
|
||||
// Sort by centrality score descending
|
||||
scoredNotes.sort((a, b) => b.centralityScore - a.centralityScore)
|
||||
|
||||
return scoredNotes.slice(0, limit)
|
||||
} catch (error) {
|
||||
console.error('Error calculating centrality:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
56
src/lib/features.ts
Normal file
56
src/lib/features.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Feature flags for MVP-3 features.
|
||||
* Can be toggled via environment variables or local config.
|
||||
*/
|
||||
|
||||
export interface FeatureFlags {
|
||||
centrality: boolean
|
||||
passiveRecommendations: boolean
|
||||
typeSuggestions: boolean
|
||||
linkSuggestions: boolean
|
||||
}
|
||||
|
||||
// Default values - all enabled unless explicitly disabled
|
||||
const defaults: FeatureFlags = {
|
||||
centrality: true,
|
||||
passiveRecommendations: true,
|
||||
typeSuggestions: true,
|
||||
linkSuggestions: true,
|
||||
}
|
||||
|
||||
// Environment variable parsing
|
||||
function parseEnvBool(key: string, defaultValue: boolean): boolean {
|
||||
const envValue = process.env[key]
|
||||
if (envValue === undefined) return defaultValue
|
||||
if (envValue === 'true' || envValue === '1') return true
|
||||
if (envValue === 'false' || envValue === '0') return false
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current feature flags.
|
||||
* Reads from environment variables with defaults.
|
||||
*/
|
||||
export function getFeatureFlags(): FeatureFlags {
|
||||
return {
|
||||
centrality: parseEnvBool('FLAG_CENTRALITY', defaults.centrality),
|
||||
passiveRecommendations: parseEnvBool('FLAG_PASSIVE_RECOMMENDATIONS', defaults.passiveRecommendations),
|
||||
typeSuggestions: parseEnvBool('FLAG_TYPE_SUGGESTIONS', defaults.typeSuggestions),
|
||||
linkSuggestions: parseEnvBool('FLAG_LINK_SUGGESTIONS', defaults.linkSuggestions),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific feature is enabled.
|
||||
*/
|
||||
export function isFeatureEnabled(feature: keyof FeatureFlags): boolean {
|
||||
return getFeatureFlags()[feature]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get flags for client-side use (only boolean values).
|
||||
* This is safe to expose to the client.
|
||||
*/
|
||||
export function getClientFeatureFlags(): FeatureFlags {
|
||||
return getFeatureFlags()
|
||||
}
|
||||
72
src/lib/link-suggestions.ts
Normal file
72
src/lib/link-suggestions.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export interface LinkSuggestion {
|
||||
term: string
|
||||
noteId: string
|
||||
noteTitle: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Find potential wiki-link suggestions in content.
|
||||
* Returns notes whose titles appear in the content and could be converted to [[links]].
|
||||
*/
|
||||
export async function findLinkSuggestions(
|
||||
content: string,
|
||||
currentNoteId?: string
|
||||
): Promise<LinkSuggestion[]> {
|
||||
if (!content.trim() || content.length < 10) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Get all note titles except current note
|
||||
const allNotes = await prisma.note.findMany({
|
||||
where: currentNoteId ? { id: { not: currentNoteId } } : undefined,
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
|
||||
if (allNotes.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Find titles that appear in content (case-insensitive)
|
||||
const suggestions: LinkSuggestion[] = []
|
||||
const contentLower = content.toLowerCase()
|
||||
|
||||
for (const note of allNotes) {
|
||||
const titleLower = note.title.toLowerCase()
|
||||
// Check if title appears as a whole word in content
|
||||
const regex = new RegExp(`\\b${escapeRegex(titleLower)}\\b`, 'i')
|
||||
if (regex.test(content)) {
|
||||
suggestions.push({
|
||||
term: note.title,
|
||||
noteId: note.id,
|
||||
noteTitle: note.title,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by title length (longer titles first - more specific matches)
|
||||
return suggestions.sort((a, b) => b.noteTitle.length - a.noteTitle.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace terms in content with wiki-links
|
||||
*/
|
||||
export function applyWikiLinks(
|
||||
content: string,
|
||||
replacements: { term: string; noteId: string }[]
|
||||
): string {
|
||||
let result = content
|
||||
|
||||
for (const { term, noteId } of replacements) {
|
||||
// Replace all occurrences of the term (case-insensitive, whole word only)
|
||||
const regex = new RegExp(`\\b(${escapeRegex(term)})\\b`, 'gi')
|
||||
result = result.replace(regex, `[[${term}]]`)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
110
src/lib/metrics.ts
Normal file
110
src/lib/metrics.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export interface DashboardMetrics {
|
||||
totalNotes: number
|
||||
totalTags: number
|
||||
notesByType: Record<string, number>
|
||||
topTags: { name: string; count: number }[]
|
||||
topViewedNotes: { id: string; title: string; type: string; views: number }[]
|
||||
creationSourceStats: { form: number; quick: number; import: number }
|
||||
}
|
||||
|
||||
export async function getDashboardMetrics(days = 30): Promise<DashboardMetrics> {
|
||||
try {
|
||||
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
||||
|
||||
// Get totals
|
||||
const [totalNotes, totalTags, notesByTypeResult, topTagsResult] = await Promise.all([
|
||||
prisma.note.count(),
|
||||
prisma.tag.count(),
|
||||
prisma.note.groupBy({
|
||||
by: ['type'],
|
||||
_count: { id: true },
|
||||
}),
|
||||
prisma.noteTag.groupBy({
|
||||
by: ['tagId'],
|
||||
_count: { noteId: true },
|
||||
orderBy: { _count: { noteId: 'desc' } },
|
||||
take: 10,
|
||||
}),
|
||||
])
|
||||
|
||||
// Get top viewed notes from usage
|
||||
const topUsage = await prisma.noteUsage.groupBy({
|
||||
by: ['noteId'],
|
||||
where: {
|
||||
eventType: 'view',
|
||||
createdAt: { gte: since },
|
||||
},
|
||||
_count: { id: true },
|
||||
orderBy: { _count: { id: 'desc' } },
|
||||
take: 10,
|
||||
})
|
||||
|
||||
const topViewedNotes = await Promise.all(
|
||||
topUsage.map(async (usage) => {
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: usage.noteId },
|
||||
select: { id: true, title: true, type: true },
|
||||
})
|
||||
return note
|
||||
? {
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
type: note.type,
|
||||
views: usage._count.id,
|
||||
}
|
||||
: null
|
||||
})
|
||||
)
|
||||
|
||||
// Get creation source stats
|
||||
const creationSourceStats = await prisma.note.groupBy({
|
||||
by: ['creationSource'],
|
||||
_count: { id: true },
|
||||
})
|
||||
|
||||
const sourceMap = { form: 0, quick: 0, import: 0 }
|
||||
for (const stat of creationSourceStats) {
|
||||
if (stat.creationSource in sourceMap) {
|
||||
sourceMap[stat.creationSource as keyof typeof sourceMap] = stat._count.id
|
||||
}
|
||||
}
|
||||
|
||||
// Get tag names
|
||||
const tagIds = topTagsResult.map((t) => t.tagId)
|
||||
const tags = await prisma.tag.findMany({
|
||||
where: { id: { in: tagIds } },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
const tagMap = new Map(tags.map((t) => [t.id, t.name]))
|
||||
|
||||
const topTags = topTagsResult
|
||||
.map((t) => ({
|
||||
name: tagMap.get(t.tagId) || 'unknown',
|
||||
count: t._count.noteId,
|
||||
}))
|
||||
.filter((t) => t.name !== 'unknown')
|
||||
|
||||
return {
|
||||
totalNotes,
|
||||
totalTags,
|
||||
notesByType: Object.fromEntries(
|
||||
notesByTypeResult.map((r) => [r.type, r._count.id])
|
||||
),
|
||||
topTags,
|
||||
topViewedNotes: topViewedNotes.filter((n): n is NonNullable<typeof n> => n !== null),
|
||||
creationSourceStats: sourceMap,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting dashboard metrics:', error)
|
||||
return {
|
||||
totalNotes: 0,
|
||||
totalTags: 0,
|
||||
notesByType: {},
|
||||
topTags: [],
|
||||
topViewedNotes: [],
|
||||
creationSourceStats: { form: 0, quick: 0, import: 0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
143
src/lib/type-inference.ts
Normal file
143
src/lib/type-inference.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { NoteType } from '@/types/note'
|
||||
|
||||
interface TypeSuggestion {
|
||||
type: NoteType
|
||||
confidence: 'high' | 'medium' | 'low'
|
||||
reason: string
|
||||
}
|
||||
|
||||
// Patterns that indicate specific note types
|
||||
const PATTERNS = {
|
||||
command: [
|
||||
/^\s*(git|docker|npm|yarn|node|python|curl|wget|ssh|scp|rsync|kubectl|helm|aws|gcloud|az)\s+/m,
|
||||
/^\$\s*\w+/m,
|
||||
/^>\s*\w+/m,
|
||||
/`{3}(bash|sh|shell|zsh|fish)/m,
|
||||
/#!/m, // shebang
|
||||
],
|
||||
snippet: [
|
||||
/`{3}\w*/m, // code block with language
|
||||
/^(function|const|let|var|class|import|export|def|async|await)\s+/m,
|
||||
/\{\s*[\w\s]*:\s*[\w\s,}]+\}/m, // object literal
|
||||
/=\s*>\s*{/m, // arrow function
|
||||
/if\s*\(.+\)\s*{/m, // if statement
|
||||
],
|
||||
procedure: [
|
||||
/^\d+[\.\)]\s+\w+/m, // numbered steps: 1. Do this
|
||||
/^[-*]\s+\w+/m, // bullet steps: - Do this
|
||||
/primer[oay]|segundo|tercero|cuarto|finalmente|después|antes|mientras|m paso/m,
|
||||
/pasos?|steps?|instructions?|how to|tutorial/i,
|
||||
],
|
||||
recipe: [
|
||||
/ingredientes?:?\s*$/im,
|
||||
/^\s*-\s*\d+\s+\w+/m, // ingredient list: - 2 cups flour
|
||||
/tiempo|horas?|minutos|preparación|cocción|servir/i,
|
||||
/receta|recetas|cocina|cocinar|hornear|hervir/i,
|
||||
],
|
||||
decision: [
|
||||
/decisión?:?\s*/im,
|
||||
/alternativas?:?\s*/im,
|
||||
/pros?\s*y\s*contras?:?\s*/im,
|
||||
/consideramos?|optamos?|decidimos?|elegimos?/i,
|
||||
/porque?|reason|context|vista|motivo/i,
|
||||
],
|
||||
inventory: [
|
||||
/cantidad?:?\s*\d+/im,
|
||||
/ubicación?:?\s*/im,
|
||||
/stock|inventario|existencia|dispoble/i,
|
||||
/^\s*\w+\s+\d+\s+\w+/m, // item quantity location pattern
|
||||
],
|
||||
}
|
||||
|
||||
export function inferNoteType(content: string): TypeSuggestion | null {
|
||||
const scores: Record<NoteType, { score: number; matchedPatterns: string[] }> = {
|
||||
command: { score: 0, matchedPatterns: [] },
|
||||
snippet: { score: 0, matchedPatterns: [] },
|
||||
procedure: { score: 0, matchedPatterns: [] },
|
||||
recipe: { score: 0, matchedPatterns: [] },
|
||||
decision: { score: 0, matchedPatterns: [] },
|
||||
inventory: { score: 0, matchedPatterns: [] },
|
||||
note: { score: 0, matchedPatterns: [] },
|
||||
}
|
||||
|
||||
// Check each type's patterns
|
||||
for (const [type, patterns] of Object.entries(PATTERNS)) {
|
||||
for (const pattern of patterns) {
|
||||
if (pattern.test(content)) {
|
||||
scores[type as NoteType].score += 1
|
||||
scores[type as NoteType].matchedPatterns.push(pattern.source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find the type with highest score
|
||||
let bestType: NoteType = 'note'
|
||||
let bestScore = 0
|
||||
|
||||
for (const [type, { score }] of Object.entries(scores)) {
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestType = type as NoteType
|
||||
}
|
||||
}
|
||||
|
||||
// Determine confidence based on score
|
||||
let confidence: 'high' | 'medium' | 'low' = 'low'
|
||||
let reason = 'No clear pattern detected'
|
||||
|
||||
if (bestScore >= 3) {
|
||||
confidence = 'high'
|
||||
} else if (bestScore >= 2) {
|
||||
confidence = 'medium'
|
||||
} else if (bestScore >= 1) {
|
||||
confidence = 'low'
|
||||
}
|
||||
|
||||
if (bestScore > 0) {
|
||||
switch (bestType) {
|
||||
case 'command':
|
||||
reason = 'Shell command patterns detected'
|
||||
break
|
||||
case 'snippet':
|
||||
reason = 'Code block or programming syntax detected'
|
||||
break
|
||||
case 'procedure':
|
||||
reason = 'Step-by-step instructions detected'
|
||||
break
|
||||
case 'recipe':
|
||||
reason = 'Recipe or cooking instructions detected'
|
||||
break
|
||||
case 'decision':
|
||||
reason = 'Decision-making context detected'
|
||||
break
|
||||
case 'inventory':
|
||||
reason = 'Inventory or quantity patterns detected'
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: bestType,
|
||||
confidence,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
export function formatContentForType(content: string, type: NoteType): string {
|
||||
switch (type) {
|
||||
case 'command':
|
||||
return `## Comando\n\n${content.trim()}\n\n## Qué hace\n\n[Descripción]\n\n## Cuándo usarlo\n\n[Cuándo usar este comando]\n\n## Ejemplo\n\n\`\`\`bash\n[ejemplo]\n\`\`\``
|
||||
case 'snippet':
|
||||
return `## Snippet\n\n## Lenguaje\n\n[ Lenguaje ]\n\n## Código\n\n\`\`\`\n${content.trim()}\n\`\`\`\n\n## Qué resuelve\n\n[Descripción del problema que resuelve]\n\n## Notas\n\n[Notas adicionales]`
|
||||
case 'procedure':
|
||||
return `## Objetivo\n\n[Cuál es el objetivo]\n\n## Pasos\n\n${content.trim()}\n\n## Requisitos\n\n[Requisitos necesarios]\n\n## Problemas comunes\n\n[Problemas frecuentes y soluciones]`
|
||||
case 'recipe':
|
||||
return `## Ingredientes\n\n[Lista de ingredientes]\n\n## Pasos\n\n${content.trim()}\n\n## Tiempo\n\n[Tiempo de preparación]\n\n## Notas\n\n[Notas adicionales]`
|
||||
case 'decision':
|
||||
return `## Contexto\n\n[Cuál era la situación]\n\n## Decisión\n\n${content.trim()}\n\n## Alternativas consideradas\n\n[Otras opciones evaluadas]\n\n## Consecuencias\n\n[Impacto de esta decisión]`
|
||||
case 'inventory':
|
||||
return `## Item\n\n[Nombre del item]\n\n## Cantidad\n\n[Cantidad]\n\n## Ubicación\n\n[Ubicación]\n\n## Notas\n\n[Notas adicionales]`
|
||||
default:
|
||||
return `## Notas\n\n${content.trim()}`
|
||||
}
|
||||
}
|
||||
@@ -136,3 +136,67 @@ export function clearRecentlyViewed(): void {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
// Co-usage tracking: record that two notes were viewed together
|
||||
export async function trackCoUsage(fromNoteId: string, toNoteId: string): Promise<void> {
|
||||
if (fromNoteId === toNoteId) return
|
||||
try {
|
||||
await prisma.noteCoUsage.upsert({
|
||||
where: {
|
||||
fromNoteId_toNoteId: { fromNoteId, toNoteId },
|
||||
},
|
||||
update: {
|
||||
weight: { increment: 1 },
|
||||
},
|
||||
create: {
|
||||
fromNoteId,
|
||||
toNoteId,
|
||||
weight: 1,
|
||||
},
|
||||
})
|
||||
} catch {
|
||||
// Silently fail
|
||||
}
|
||||
}
|
||||
|
||||
// Get notes that are often viewed together with a given note
|
||||
export async function getCoUsedNotes(
|
||||
noteId: string,
|
||||
limit = 5,
|
||||
days = 30
|
||||
): Promise<{ noteId: string; title: string; type: string; weight: number }[]> {
|
||||
try {
|
||||
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000)
|
||||
const coUsages = await prisma.noteCoUsage.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ fromNoteId: noteId },
|
||||
{ toNoteId: noteId },
|
||||
],
|
||||
updatedAt: { gte: since },
|
||||
},
|
||||
orderBy: { weight: 'desc' },
|
||||
take: limit,
|
||||
})
|
||||
|
||||
const result: { noteId: string; title: string; type: string; weight: number }[] = []
|
||||
for (const cu of coUsages) {
|
||||
const relatedNoteId = cu.fromNoteId === noteId ? cu.toNoteId : cu.fromNoteId
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: relatedNoteId },
|
||||
select: { id: true, title: true, type: true },
|
||||
})
|
||||
if (note) {
|
||||
result.push({
|
||||
noteId: note.id,
|
||||
title: note.title,
|
||||
type: note.type,
|
||||
weight: cu.weight,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { z } from 'zod'
|
||||
|
||||
export const NoteTypeEnum = z.enum(['command', 'snippet', 'decision', 'recipe', 'procedure', 'inventory', 'note'])
|
||||
|
||||
export const CreationSourceEnum = z.enum(['form', 'quick', 'import'])
|
||||
|
||||
export const noteSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
@@ -10,6 +12,7 @@ export const noteSchema = z.object({
|
||||
isFavorite: z.boolean().default(false),
|
||||
isPinned: z.boolean().default(false),
|
||||
tags: z.array(z.string()).optional(),
|
||||
creationSource: CreationSourceEnum.default('form'),
|
||||
})
|
||||
|
||||
export const updateNoteSchema = noteSchema.partial().extend({
|
||||
|
||||
Reference in New Issue
Block a user