sepehr 7fb486c9a4 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>
2026-01-11 22:26:13 +01:00

1596 lines
49 KiB
Markdown

---
stepsCompleted: [1, 2, 3, 4, 5]
workflow_completed: true
inputDocuments:
- _bmad-output/planning-artifacts/prd-phase1-mvp-ai.md
- _bmad-output/planning-artifacts/ux-design-specification.md
- _bmad-output/planning-artifacts/architecture.md
- _bmad-output/analysis/brainstorming-session-2026-01-09.md
workflow_type: 'create-epics-and-stories'
project_name: 'Keep (Memento Phase 1 MVP AI)'
user_name: 'Ramez'
date: '2026-01-10'
focus_area: 'Phase 1 MVP AI - AI-Powered Note Taking Features'
communication_language: 'French'
document_output_language: 'English'
status: 'completed'
---
# Keep (Memento) - Epic Breakdown - Phase 1 MVP AI
## Overview
This document provides the complete epic and story breakdown for **Keep Phase 1 MVP AI**, decomposing the requirements from the Phase 1 PRD, UX Design Specification, and Architecture into implementable stories.
**Project Context:** Brownfield extension of existing Keep Notes application with AI-powered features. Zero breaking changes to existing functionality.
**Implementation Timeline:** 12 weeks (4 phases)
**Target:** Production-ready MVP with 6 core AI features
---
## Requirements Inventory
### Functional Requirements - Phase 1 MVP
**Core AI Features:**
- **FR6:** Real-time content analysis for concept identification
- **FR7:** AI-powered tag suggestions based on content analysis
- **FR8:** User control over AI suggestions (accept/modify/reject)
- **FR11:** Exact keyword search (title and content)
- **FR12:** Semantic search by meaning/intention (natural language)
- **FR13:** Hybrid search combining exact + semantic results
**Foundation Features (Already Implemented):**
- **FR1:** CRUD operations for notes (text and checklist)
- **FR2:** Pin notes to top of list
- **FR3:** Archive notes
- **FR4:** Attach images to notes
- **FR5:** Drag-and-drop reordering (Muuri)
- **FR9:** Manual tag management
- **FR10:** Filter and sort by tags
- **FR16:** Optimistic UI for immediate feedback
**Configuration & Administration:**
- **FR17:** AI provider configuration (OpenAI, Ollama)
- **FR18:** Multi-provider support via Vercel AI SDK
- **FR19:** Theme customization (dark mode)
**Deferred to Phase 2/3:**
- **FR14:** Offline PWA mode
- **FR15:** Background sync
### Non-Functional Requirements
**Performance:**
- **NFR1:** Auto-tagging < 1.5s after typing ends
- **NFR2:** Semantic search < 300ms for 1000 notes
- **NFR3:** Title suggestions < 2s after detection
**Security & Privacy:**
- **NFR4:** API key isolation (server-side only)
- **NFR5:** Local-first privacy (Ollama = 100% local)
**Reliability:**
- **NFR8:** Vector integrity (automatic background updates)
**Portability:**
- **NFR9:** Minimal footprint (Zero DevOps)
- **NFR10:** Node.js LTS support
---
## Phase 1 MVP AI Epic Mapping
### Epic 1: Intelligent Title Suggestions ⭐
**Focus:** AI-powered title generation for untitled notes
**FRs covered:** FR6, FR8
**Architecture Decision:** Decision 1 (Database Schema), Decision 3 (Language Detection)
**Priority:** HIGH (Core user experience feature)
### Epic 2: Hybrid Semantic Search 🔍
**Focus:** Keyword + vector search with RRF fusion
**FRs covered:** FR11, FR12, FR13
**Architecture Decision:** Decision 1 (Database Schema - reuses Note.embedding)
**Priority:** HIGH (Core discovery feature)
### Epic 3: Paragraph-Level Reformulation ✍️
**Focus:** AI-powered text improvement (Clarify, Shorten, Improve Style)
**FRs covered:** FR6, FR8
**Architecture Decision:** Decision 1 (Database Schema - no schema change)
**Priority:** MEDIUM (User productivity feature)
### Epic 4: Memory Echo (Proactive Connections) 🧠
**Focus:** Daily proactive note connections via cosine similarity
**FRs covered:** FR6
**Architecture Decision:** Decision 2 (Memory Echo Architecture)
**Priority:** HIGH (Differentiating feature)
### Epic 5: AI Settings Panel ⚙️
**Focus:** Granular ON/OFF controls per feature + provider selection
**FRs covered:** FR17, FR18
**Architecture Decision:** Decision 4 (AI Settings Architecture)
**Priority:** HIGH (User control requirement)
### Epic 6: Language Detection Service 🌐
**Focus:** Automatic language detection (TinyLD hybrid approach)
**FRs covered:** FR6 (Cross-cutting concern)
**Architecture Decision:** Decision 3 (Language Detection Strategy)
**Priority:** HIGH (Enables multilingual prompts)
---
## Epic 1: Intelligent Title Suggestions
### Overview
Generate 3 AI-powered title suggestions when a note reaches 50+ words without a title. User can accept, modify, or reject suggestions.
**User Stories:** 3
**Estimated Complexity:** Medium
**Dependencies:** Language Detection Service, AI Provider Factory
### Story 1.1: Real-time Word Count Detection
**As a user, I want the system to detect when my note reaches 50+ words without a title, so that I can receive title suggestions automatically.**
**Acceptance Criteria:**
- **Given** an open note editor
- **When** I type content and the word count reaches 50+
- **And** the note title field is empty
- **Then** the system triggers background title generation
- **And** a non-intrusive toast notification appears: "💡 Title suggestions available"
**Technical Requirements:**
- Word count triggered on `debounce` (300ms after typing stops)
- Detection logic: `content.split(/\s+/).length >= 50`
- Must not interfere with typing experience (non-blocking)
- Toast notification uses Sonner (radix-ui compatible)
**Implementation Files:**
- Component: `keep-notes/components/ai/ai-suggestion.tsx` (NEW)
- Hook: `useWordCountDetection` (NEW utility)
- UI: Toast notification with "View" / "Dismiss" actions
---
### Story 1.2: AI Title Generation
**As a system, I want to generate 3 relevant title suggestions using AI, so that users can quickly organize their notes.**
**Acceptance Criteria:**
- **Given** a note with 50+ words of content
- **When** title generation is triggered
- **Then** the AI generates 3 distinct title suggestions
- **And** each title is concise (3-8 words)
- **And** titles reflect the main concept of the content
- **And** generation completes within < 2 seconds
**Technical Requirements:**
- Service: `TitleSuggestionService` in `lib/ai/services/title-suggestion.service.ts`
- Provider: Uses `getAIProvider()` factory (OpenAI or Ollama)
- System Prompt: English (stability)
- User Data: Local language (FR, EN, ES, DE, FA, etc.)
- Language Detection: Called before generation for multilingual prompts
- Storage: Suggestions stored in memory (not persisted until user accepts)
**Prompt Engineering:**
```
System: You are a title generator. Generate 3 concise titles (3-8 words each) that capture the main concept.
User Language: {detected_language}
Content: {note_content}
Output format: JSON array of strings
```
**Error Handling:**
- If AI fails: Retry once with different provider (if available)
- If retry fails: Show toast error "Failed to generate suggestions. Please try again."
- Timeout: 5 seconds maximum
---
### Story 1.3: User Interaction & Feedback
**As a user, I want to accept, modify, or reject title suggestions, so that I maintain full control over my note organization.**
**Acceptance Criteria:**
- **Given** 3 AI-generated title suggestions
- **When** I click the toast notification
- **Then** a modal displays the 3 suggestions
- **And** I can click any suggestion to apply it as the note title
- **And** I can click "Dismiss" to ignore all suggestions
- **And** the modal closes automatically after selection or dismissal
**Technical Requirements:**
- Component: `AiSuggestionModal` (extends `components/ai/ai-suggestion.tsx`)
- Server Action: `updateNote(noteId, { title })`
- Feedback: Store user choice in `AiFeedback` table
- `feedbackType`: 'thumbs_up' if accepted without modification
- `feature`: 'title_suggestion'
- `originalContent`: All 3 suggestions (JSON array)
- `correctedContent`: User's final choice (or modified title)
**UI/UX Requirements (from UX Design Spec):**
- Modal design: Clean, centered, with card-style suggestions
- Each suggestion: Clickable card with hover effect
- "Dismiss" button: Secondary action at bottom
- Auto-close after selection (no confirmation dialog)
- If user modifies title: Record as 'correction' feedback
**Implementation Files:**
- Modal: `components/ai/ai-suggestion.tsx` (NEW)
- Server Action: `app/actions/ai-suggestions.ts` (NEW)
- API Route: `/api/ai/feedback` (NEW) - stores feedback
**Database Updates:**
```typescript
// When user accepts a title
await prisma.note.update({
where: { id: noteId },
data: {
title: selectedTitle,
autoGenerated: true,
aiProvider: currentProvider,
aiConfidence: 85, // Placeholder - Phase 3 will calculate
lastAiAnalysis: new Date()
}
})
// Store feedback for Phase 3 trust scoring
await prisma.aiFeedback.create({
data: {
noteId,
userId: session.user.id,
feedbackType: 'thumbs_up',
feature: 'title_suggestion',
originalContent: JSON.stringify(allThreeSuggestions),
correctedContent: selectedTitle,
metadata: JSON.stringify({
provider: currentProvider,
model: modelName,
timestamp: new Date()
})
}
})
```
---
## Epic 2: Hybrid Semantic Search
### Overview
Combine exact keyword matching with vector similarity search using Reciprocal Rank Fusion (RRF) for comprehensive results.
**User Stories:** 3
**Estimated Complexity:** High
**Dependencies:** Existing embeddings system, Language Detection (optional)
### Story 2.1: Query Embedding Generation
**As a system, I want to generate vector embeddings for user search queries, so that I can find notes by meaning.**
**Acceptance Criteria:**
- **Given** a user search query
- **When** the search is executed
- **Then** the system generates a vector embedding for the query
- **And** the embedding is stored in memory (not persisted)
- **And** generation completes within < 200ms
**Technical Requirements:**
- Service: `SemanticSearchService` in `lib/ai/services/semantic-search.service.ts`
- Provider: Uses `getAIProvider()` factory
- Embedding Model: `text-embedding-3-small` (OpenAI) or Ollama equivalent
- Language Detection: Optional (can detect query language for better results)
- Caching: Query embeddings cached in React Cache (5-minute TTL)
**Implementation:**
```typescript
// lib/ai/services/semantic-search.service.ts
async generateQueryEmbedding(query: string): Promise<number[]> {
const provider = getAIProvider()
const embedding = await provider.generateEmbedding(query)
return embedding
}
```
---
### Story 2.2: Vector Similarity Calculation
**As a system, I want to calculate cosine similarity between query and all user notes, so that I can rank results by meaning.**
**Acceptance Criteria:**
- **Given** a query embedding and all user note embeddings
- **When** similarity calculation runs
- **Then** the system calculates cosine similarity for each note
- **And** returns notes ranked by similarity score (descending)
- **And** calculation completes within < 300ms for 1000 notes
**Technical Requirements:**
- Algorithm: Cosine similarity
- Formula: `similarity = dotProduct(queryEmbedding, noteEmbedding) / (magnitude(query) * magnitude(note))`
- Threshold: Notes with similarity < 0.3 are filtered out
- Performance: In-memory calculation (no separate vector DB for Phase 1)
**Implementation:**
```typescript
// lib/ai/services/semantic-search.service.ts
async searchBySimilarity(
queryEmbedding: number[],
userId: string
): Promise<Array<{ note: Note, score: number }>> {
// Fetch all user notes with embeddings
const notes = await prisma.note.findMany({
where: { userId },
select: { id: true, title: true, content: true, embedding: true }
})
// Calculate cosine similarity
const results = notes
.map(note => ({
note,
score: cosineSimilarity(queryEmbedding, JSON.parse(note.embedding))
}))
.filter(r => r.score > 0.3) // Threshold filter
.sort((a, b) => b.score - a.score)
return results
}
```
---
### Story 2.3: Hybrid Search with RRF Fusion
**As a user, I want to see combined results from keyword search and semantic search, so that I get the most comprehensive results.**
**Acceptance Criteria:**
- **Given** a search query
- **When** I execute the search
- **Then** the system performs BOTH keyword search AND semantic search
- **And** results are fused using Reciprocal Rank Fusion (RRF)
- **And** each result displays a badge: "Exact Match" or "Related"
- **And** total time < 300ms for 1000 notes
**Technical Requirements:**
- Service: `SemanticSearchService` (extends from Story 2.1, 2.2)
- Fusion Algorithm: Reciprocal Rank Fusion (RRF)
- `RRF(score) = 1 / (k + rank)` where k = 60 (standard value)
- Combined score = `RRF(keyword_rank) + RRF(semantic_rank)`
- Keyword Search: Existing Prisma query (title/content LIKE `%query%`)
- Semantic Search: Cosine similarity from Story 2.2
- Result Limit: Top 20 notes
**RRF Implementation:**
```typescript
// lib/ai/services/semantic-search.service.ts
async hybridSearch(
query: string,
userId: string
): Promise<Array<{ note: Note, keywordScore: number, semanticScore: number, combinedScore: number }>> {
// Parallel execution
const [keywordResults, semanticResults] = await Promise.all([
this.keywordSearch(query, userId), // Existing implementation
this.searchBySimilarity(query, userId) // Story 2.2
])
// Calculate RRF scores
const k = 60
const scoredNotes = new Map<string, any>()
// Add keyword RRF scores
keywordResults.forEach((note, index) => {
const rrf = 1 / (k + index + 1)
scoredNotes.set(note.id, {
note,
keywordScore: rrf,
semanticScore: 0,
combinedScore: rrf
})
})
// Add semantic RRF scores and combine
semanticResults.forEach(({ note, score }, index) => {
const rrf = 1 / (k + index + 1)
if (scoredNotes.has(note.id)) {
const existing = scoredNotes.get(note.id)
existing.semanticScore = rrf
existing.combinedScore += rrf
} else {
scoredNotes.set(note.id, {
note,
keywordScore: 0,
semanticScore: rrf,
combinedScore: rrf
})
}
})
// Convert to array and sort by combined score
return Array.from(scoredNotes.values())
.sort((a, b) => b.combinedScore - a.combinedScore)
.slice(0, 20) // Top 20 results
}
```
**UI Requirements (from UX Design Spec):**
- Component: `components/ai/semantic-search-results.tsx` (NEW)
- Badge display:
- "Exact Match" badge: Blue background, shown if `keywordScore > 0`
- "Related" badge: Gray background, shown if `semanticScore > 0` AND `keywordScore === 0`
- Both badges can appear if note matches both
- Result card: Displays title, content snippet (100 chars), badges
- Loading state: Skeleton cards while searching (< 300ms)
**API Route:**
- Endpoint: `POST /api/ai/search`
- Request schema:
```typescript
{ query: string, userId: string }
```
- Response:
```typescript
{
success: true,
data: {
results: Array<{
note: Note,
badges: Array<"Exact Match" | "Related">
}>,
totalResults: number,
searchTime: number // milliseconds
}
}
```
---
## Epic 3: Paragraph-Level Reformulation
### Overview
AI-powered text improvement with 3 options: Clarify, Shorten, Improve Style. Triggered via context menu on text selection.
**User Stories:** 2
**Estimated Complexity:** Medium
**Dependencies:** AI Provider Factory
### Story 3.1: Context Menu Integration
**As a user, I want to select text and see "Reformulate" options in a context menu, so that I can improve my writing with AI assistance.**
**Acceptance Criteria:**
- **Given** a note editor with text content
- **When** I select one or more paragraphs (50-500 words)
- **And** I right-click or long-press
- **Then** a context menu appears with "Reformulate" submenu
- **And** the submenu shows 3 options: "Clarify", "Shorten", "Improve Style"
- **When** I click any option
- **Then** the selected text is sent to AI for reformulation
- **And** a loading indicator appears on the selected text
**Technical Requirements:**
- Component: `components/ai/paragraph-refactor.tsx` (NEW)
- Context Menu: Extends existing note editor context menu (Radix Dropdown Menu)
- Text Selection: `window.getSelection()` API
- Word Count Validation: 50-500 words (show error if out of range)
- Loading State: Skeleton or spinner overlay on selected text
**UI Implementation:**
```typescript
// components/ai/paragraph-refactor.tsx
'use client'
import { useCallback } from 'react'
import { startTransition } from 'react'
export function ParagraphRefactor({ noteId, content }: { noteId: string, content: string }) {
const handleTextSelection = useCallback(() => {
const selection = window.getSelection()
const selectedText = selection?.toString()
const wordCount = selectedText?.split(/\s+/).length || 0
if (wordCount < 50 || wordCount > 500) {
showError('Please select 50-500 words to reformulate')
return
}
// Show context menu at selection position
showContextMenu(selection.getRangeAt(0))
}, [])
const handleRefactor = async (option: 'clarify' | 'shorten' | 'improve') => {
const selectedText = window.getSelection()?.toString()
startTransition(async () => {
showLoadingState()
const result = await refactorParagraph(noteId, selectedText, option)
hideLoadingState()
showRefactorDialog(result.refactoredText)
})
}
return (
// Context menu integration
<DropdownMenu>
<DropdownMenuTrigger>Reformulate</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={() => handleRefactor('clarify')}>
Clarify
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRefactor('shorten')}>
Shorten
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleRefactor('improve')}>
Improve Style
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
```
---
### Story 3.2: AI Reformulation & Application
**As a user, I want to see AI-reformulated text and choose to apply or discard it, so that I can improve my writing while maintaining control.**
**Acceptance Criteria:**
- **Given** selected text sent for reformulation
- **When** AI completes processing (< 2 seconds)
- **Then** a modal displays showing:
- Original text (left side)
- Reformulated text (right side) with diff highlighting
- "Apply" and "Discard" buttons
- **When** I click "Apply"
- **Then** the reformulated text replaces the original in the note
- **And** the change is saved automatically
- **When** I click "Discard"
- **Then** the modal closes and no changes are made
**Technical Requirements:**
- Service: `ParagraphRefactorService` in `lib/ai/services/paragraph-refactor.service.ts`
- Provider: Uses `getAIProvider()` factory
- System Prompt: English (stability)
- User Data: Local language (respects language detection)
- Diff Display: Use `react-diff-viewer` or similar library
**Prompt Engineering:**
```
System: You are a text reformulator. Reformulate the text according to the user's chosen option.
User Language: {detected_language}
Option: {clarify|shorten|improve}
Clarify: Make the text clearer and easier to understand
Shorten: Reduce word count by 30-50% while keeping key information
Improve Style: Enhance readability, flow, and professional tone
Original Text:
{selected_text}
Output: Reformulated text only (no explanations)
```
**UI Implementation:**
```typescript
// Modal component (extends paragraph-refactor.tsx)
export function RefactorModal({
originalText,
refactoredText,
onApply,
onDiscard
}) {
return (
<Dialog open={true}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>Compare & Apply</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="font-medium mb-2">Original</h4>
<div className="p-4 bg-gray-100 rounded">
{originalText}
</div>
</div>
<div>
<h4 className="font-medium mb-2">Refactored</h4>
<div className="p-4 bg-blue-50 rounded">
{refactoredText}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onDiscard}>
Discard
</Button>
<Button onClick={onApply}>
Apply Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
```
**Server Action:**
```typescript
// app/actions/ai-suggestions.ts
'use server'
import { auth } from '@/auth'
import { ParagraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
import { updateNote } from './notes'
export async function refactorParagraph(
noteId: string,
selectedText: string,
option: 'clarify' | 'shorten' | 'improve'
) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const service = new ParagraphRefactorService()
const refactoredText = await service.refactor(selectedText, option)
return {
success: true,
originalText: selectedText,
refactoredText
}
}
export async function applyRefactoring(
noteId: string,
originalText: string,
refactoredText: string
) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
// Get current note content
const note = await prisma.note.findUnique({ where: { id: noteId } })
if (!note?.userId || note.userId !== session.user.id) {
throw new Error('Note not found')
}
// Replace original text with refactored text
const newContent = note.content.replace(originalText, refactoredText)
await updateNote(noteId, { content: newContent })
return { success: true }
}
```
**Feedback Collection:**
```typescript
// Track which reformulation option users prefer
await prisma.aiFeedback.create({
data: {
noteId,
userId: session.user.id,
feedbackType: 'correction', // User chose to apply
feature: 'paragraph_refactor',
originalContent: originalText,
correctedContent: refactoredText,
metadata: JSON.stringify({
option, // 'clarify' | 'shorten' | 'improve'
provider: currentProvider,
timestamp: new Date()
})
}
})
```
---
## Epic 4: Memory Echo (Proactive Connections)
### Overview
Background process that identifies connections between notes using cosine similarity. Displays 1 insight per day (max similarity > 0.75).
**User Stories:** 2
**Estimated Complexity:** High
**Dependencies:** Existing embeddings system, Decision 2 (Server Action + Queue pattern)
### Story 4.1: Background Insight Generation
**As a system, I want to analyze all user note embeddings daily to find connections, so that I can proactively suggest related notes.**
**Acceptance Criteria:**
- **Given** a user with 10+ notes (each with embeddings)
- **When** the user logs in
- **And** no insight has been generated today
- **Then** the system triggers background analysis
- **And** calculates cosine similarity between all note pairs
- **And** finds the top pair with similarity > 0.75
- **And** stores the insight in `MemoryEchoInsight` table
- **And** UI freeze is < 100ms (only DB check, background processing)
**Technical Requirements:**
- Server Action: `app/actions/ai-memory-echo.ts` (NEW)
- Service: `MemoryEchoService` in `lib/ai/services/memory-echo.service.ts` (NEW)
- Trigger: User login check (in layout or dashboard)
- Constraint: Max 1 insight per user per day (enforced via DB unique constraint)
- Performance: < 100ms UI freeze (async processing)
**Implementation:**
```typescript
// app/actions/ai-memory-echo.ts
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { MemoryEchoService } from '@/lib/ai/services/memory-echo.service'
export async function generateMemoryEcho() {
const session = await auth()
if (!session?.user?.id) {
return { success: false, error: 'Unauthorized' }
}
// Check if already generated today
const today = new Date()
today.setHours(0, 0, 0, 0)
const existing = await prisma.memoryEchoInsight.findFirst({
where: {
userId: session.user.id,
insightDate: { gte: today }
}
})
if (existing) {
return { success: true, insight: existing, alreadyGenerated: true }
}
// Generate new insight (non-blocking background task)
generateInBackground(session.user.id)
// Return immediately (UI doesn't wait)
return { success: true, insight: null, alreadyGenerated: false }
}
async function generateInBackground(userId: string) {
const service = new MemoryEchoService()
try {
const insight = await service.findTopConnection(userId)
if (insight) {
await prisma.memoryEchoInsight.create({
data: {
userId,
note1Id: insight.note1Id,
note2Id: insight.note2Id,
similarityScore: insight.score
}
})
}
} catch (error) {
console.error('Memory Echo background generation error:', error)
}
}
```
**Service Implementation:**
```typescript
// lib/ai/services/memory-echo.service.ts
export class MemoryEchoService {
async findTopConnection(
userId: string
): Promise<{ note1Id: string, note2Id: string, score: number } | null> {
// Fetch all user notes with embeddings
const notes = await prisma.note.findMany({
where: { userId },
select: { id: true, embedding: true, title: true, content: true }
})
if (notes.length < 2) return null
// Calculate pairwise cosine similarities
const insights = []
const threshold = 0.75
for (let i = 0; i < notes.length; i++) {
for (let j = i + 1; j < notes.length; j++) {
const embedding1 = JSON.parse(notes[i].embedding)
const embedding2 = JSON.parse(notes[j].embedding)
const similarity = cosineSimilarity(embedding1, embedding2)
if (similarity > threshold) {
insights.push({
note1Id: notes[i].id,
note2Id: notes[j].id,
score: similarity
})
}
}
}
// Return top insight (highest similarity)
if (insights.length === 0) return null
insights.sort((a, b) => b.score - a.score)
return insights[0]
}
}
// Cosine similarity utility
function cosineSimilarity(vecA: number[], vecB: number[]): number {
const dotProduct = vecA.reduce((sum, a, i) => sum + a * vecB[i], 0)
const magnitudeA = Math.sqrt(vecA.reduce((sum, a) => sum + a * a, 0))
const magnitudeB = Math.sqrt(vecB.reduce((sum, b) => sum + b * b, 0))
return dotProduct / (magnitudeA * magnitudeB)
}
```
---
### Story 4.2: Insight Display & Feedback
**As a user, I want to see daily note connections and provide feedback, so that I can discover relationships in my knowledge base.**
**Acceptance Criteria:**
- **Given** a stored Memory Echo insight
- **When** I log in (or navigate to dashboard)
- **Then** a toast notification appears: "💡 Memory Echo: Note X relates to Note Y (85% match)"
- **When** I click the toast
- **Then** a modal displays both notes side-by-side
- **And** I can click each note to view it in editor
- **And** I can provide feedback via 👍 / 👎 buttons
- **When** I click feedback
- **Then** the feedback is stored in `MemoryEchoInsight.feedback` field
**Technical Requirements:**
- Component: `components/ai/memory-echo-notification.tsx` (NEW)
- Trigger: Check on page load (dashboard layout)
- UI: Toast notification with Sonner
- Modal: Side-by-side note comparison
- Feedback: Updates `MemoryEchoInsight.feedback` field
**UI Implementation:**
```typescript
// components/ai/memory-echo-notification.tsx
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { Bell, X, ThumbsUp, ThumbsDown } from 'lucide-react'
import { generateMemoryEcho } from '@/app/actions/ai-memory-echo'
export function MemoryEchoNotification() {
const router = useRouter()
const [insight, setInsight] = useState<any>(null)
const [viewed, setViewed] = useState(false)
useEffect(() => {
checkForInsight()
}, [])
const checkForInsight = async () => {
const result = await generateMemoryEcho()
if (result.success && result.insight && !result.alreadyGenerated) {
// Show toast notification
toast('💡 Memory Echo', {
description: `Note "${insight.note1.title}" relates to "${insight.note2.title}" (${Math.round(insight.similarityScore * 100)}% match)`,
action: {
label: 'View',
onClick: () => showInsightModal(result.insight)
}
})
}
if (result.success && result.insight) {
setInsight(result.insight)
}
}
const showInsightModal = (insightData: any) => {
// Open modal with both notes side-by-side
setViewed(true)
markAsViewed(insightData.id)
}
const handleFeedback = async (feedback: 'thumbs_up' | 'thumbs_down') => {
await updateMemoryEchoFeedback(insight.id, feedback)
toast(feedback === 'thumbs_up' ? 'Thanks for your feedback!' : 'We\'ll improve next time')
// Close modal or hide toast
}
if (!insight) return null
return (
// Modal implementation with feedback buttons
<Dialog open={viewed} onOpenChange={setViewed}>
<DialogContent>
<DialogHeader>
<DialogTitle>Memory Echo Discovery</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-4">
{/* Note 1 */}
<NoteCard note={insight.note1} onClick={() => router.push(`/notes/${insight.note1.id}`)} />
{/* Note 2 */}
<NoteCard note={insight.note2} onClick={() => router.push(`/notes/${insight.note2.id}`)} />
</div>
<div className="text-center text-sm text-gray-600">
Similarity: {Math.round(insight.similarityScore * 100)}%
</div>
<div className="flex justify-center gap-4">
<Button
variant={insight.feedback === 'thumbs_up' ? 'default' : 'outline'}
size="icon"
onClick={() => handleFeedback('thumbs_up')}
>
<ThumbsUp className="h-4 w-4" />
</Button>
<Button
variant={insight.feedback === 'thumbs_down' ? 'default' : 'outline'}
size="icon"
onClick={() => handleFeedback('thumbs_down')}
>
<ThumbsDown className="h-4 w-4" />
</Button>
</div>
</DialogContent>
</Dialog>
)
}
```
**Server Action for Feedback:**
```typescript
// app/actions/ai-memory-echo.ts
export async function updateMemoryEchoFeedback(
insightId: string,
feedback: 'thumbs_up' | 'thumbs_down'
) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
await prisma.memoryEchoInsight.update({
where: { id: insightId },
data: { feedback }
})
return { success: true }
}
```
**Database Schema (from Architecture Decision 2):**
```prisma
model MemoryEchoInsight {
id String @id @default(cuid())
userId String?
note1Id String
note2Id String
similarityScore Float
insightDate DateTime @default(now())
viewed Boolean @default(false)
feedback String?
note1 Note @relation("EchoNote1", fields: [note1Id], references: [id])
note2 Note @relation("EchoNote2", fields: [note2Id], references: [id])
user User? @relation(fields: [userId], references: [id])
@@unique([userId, insightDate])
@@index([userId, insightDate])
}
```
---
## Epic 5: AI Settings Panel
### Overview
Dedicated settings page at `/settings/ai` with granular ON/OFF controls for each AI feature and provider selection.
**User Stories:** 2
**Estimated Complexity:** Medium
**Dependencies:** Decision 4 (UserAISettings table), AI Provider Factory
### Story 5.1: Granular Feature Toggles
**As a user, I want to enable/disable individual AI features, so that I can control which AI assistance I receive.**
**Acceptance Criteria:**
- **Given** the AI Settings page at `/settings/ai`
- **When** I navigate to the page
- **Then** I see toggles for each AI feature:
- Title Suggestions (default: ON)
- Semantic Search (default: ON)
- Paragraph Reformulation (default: ON)
- Memory Echo (default: ON)
- **When** I toggle any feature OFF
- **Then** the setting is saved to `UserAISettings` table
- **And** the feature is immediately disabled in the UI
- **When** I toggle any feature ON
- **Then** the feature is re-enabled immediately
**Technical Requirements:**
- Page: `app/(main)/settings/ai/page.tsx` (NEW)
- Component: `components/ai/ai-settings-panel.tsx` (NEW)
- Server Action: `app/actions/ai-settings.ts` (NEW)
- Database: `UserAISettings` table (from Decision 4)
**UI Implementation:**
```typescript
// app/(main)/settings/ai/page.tsx
import { AISettingsPanel } from '@/components/ai/ai-settings-panel'
import { getAISettings } from '@/lib/ai/settings'
export default async function AISettingsPage() {
const settings = await getAISettings()
return (
<div className="container mx-auto py-8">
<h1 className="text-3xl font-bold mb-6">AI Settings</h1>
<AISettingsPanel initialSettings={settings} />
</div>
)
}
// components/ai/ai-settings-panel.tsx
'use client'
import { useState } from 'react'
import { Switch } from '@/components/ui/switch'
import { Label } from '@/components/ui/label'
import { Card } from '@/components/ui/card'
import { updateAISettings } from '@/app/actions/ai-settings'
export function AISettingsPanel({ initialSettings }: { initialSettings: any }) {
const [settings, setSettings] = useState(initialSettings)
const handleToggle = async (feature: string, value: boolean) => {
// Optimistic update
setSettings(prev => ({ ...prev, [feature]: value }))
// Server update
await updateAISettings({ [feature]: value })
}
return (
<div className="space-y-6">
<FeatureToggle
name="titleSuggestions"
label="Title Suggestions"
description="Suggest titles for untitled notes after 50+ words"
checked={settings.titleSuggestions}
onChange={(checked) => handleToggle('titleSuggestions', checked)}
/>
<FeatureToggle
name="semanticSearch"
label="Semantic Search"
description="Find notes by meaning, not just keywords"
checked={settings.semanticSearch}
onChange={(checked) => handleToggle('semanticSearch', checked)}
/>
<FeatureToggle
name="paragraphRefactor"
label="Paragraph Reformulation"
description="AI-powered text improvement options"
checked={settings.paragraphRefactor}
onChange={(checked) => handleToggle('paragraphRefactor', checked)}
/>
<FeatureToggle
name="memoryEcho"
label="Memory Echo"
description="Daily proactive connections between your notes"
checked={settings.memoryEcho}
onChange={(checked) => handleToggle('memoryEcho', checked)}
/>
{settings.memoryEcho && (
<FrequencySlider
value={settings.memoryEchoFrequency}
onChange={(value) => handleToggle('memoryEchoFrequency', value)}
options={['daily', 'weekly', 'custom']}
/>
)}
</div>
)
}
function FeatureToggle({
name,
label,
description,
checked,
onChange
}: {
name: string
label: string
description: string
checked: boolean
onChange: (checked: boolean) => void
}) {
return (
<Card className="p-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<Label htmlFor={name}>{label}</Label>
<p className="text-sm text-gray-500">{description}</p>
</div>
<Switch
id={name}
checked={checked}
onCheckedChange={onChange}
/>
</div>
</Card>
)
}
```
**Server Action:**
```typescript
// app/actions/ai-settings.ts
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
export async function updateAISettings(settings: Partial<UserAISettings>) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
// Upsert settings (create if not exists)
await prisma.userAISettings.upsert({
where: { userId: session.user.id },
create: {
userId: session.user.id,
...settings
},
update: settings
})
revalidatePath('/settings/ai')
return { success: true }
}
export async function getAISettings() {
const session = await auth()
if (!session?.user?.id) {
// Return defaults for non-logged-in users
return {
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto'
}
}
const settings = await prisma.userAISettings.findUnique({
where: { userId: session.user.id }
})
return settings || {
titleSuggestions: true,
semanticSearch: true,
paragraphRefactor: true,
memoryEcho: true,
memoryEchoFrequency: 'daily',
aiProvider: 'auto'
}
}
```
---
### Story 5.2: AI Provider Selection
**As a user, I want to choose my AI provider (Auto, OpenAI, or Ollama), so that I can control cost and privacy.**
**Acceptance Criteria:**
- **Given** the AI Settings page
- **When** I scroll to the "AI Provider" section
- **Then** I see 3 provider options:
- **Auto (Recommended)** - Ollama when available, OpenAI fallback
- **Ollama (Local)** - 100% private, runs locally
- **OpenAI (Cloud)** - Most accurate, requires API key
- **When** I select a provider
- **Then** the selection is saved to `UserAISettings.aiProvider`
- **And** the AI provider factory uses my preference
**Technical Requirements:**
- Component: Extends `AISettingsPanel` with provider selector
- Integration: `getAIProvider()` factory respects user selection
- Validation: API key required for OpenAI (stored in SystemConfig)
**UI Implementation:**
```typescript
// components/ai/ai-settings-panel.tsx (extend existing component)
function ProviderSelector({
value,
onChange
}: {
value: 'auto' | 'openai' | 'ollama'
onChange: (value: 'auto' | 'openai' | 'ollama') => void
}) {
const providers = [
{
value: 'auto',
label: 'Auto (Recommended)',
description: 'Ollama when available, OpenAI fallback'
},
{
value: 'ollama',
label: 'Ollama (Local)',
description: '100% private, runs locally on your machine'
},
{
value: 'openai',
label: 'OpenAI (Cloud)',
description: 'Most accurate, requires API key'
}
]
return (
<Card className="p-4">
<Label className="text-base font-medium">AI Provider</Label>
<RadioGroup value={value} onValueChange={onChange}>
{providers.map(provider => (
<div key={provider.value} className="flex items-start space-x-2 py-2">
<RadioGroupItem value={provider.value} id={provider.value} />
<div className="grid gap-1.5">
<Label htmlFor={provider.value}>{provider.label}</Label>
<p className="text-sm text-gray-500">{provider.description}</p>
</div>
</div>
))}
</RadioGroup>
{value === 'openai' && (
<APIKeyInput />
)}
</Card>
)
}
```
**Provider Factory Integration:**
```typescript
// lib/ai/factory.ts (existing, extend to respect user settings)
import { getAIProvider } from './factory'
import { getAISettings } from './settings'
export async function getUserAIProvider(): Promise<AIProvider> {
const userSettings = await getAISettings()
const systemConfig = await getSystemConfig()
let provider = userSettings.aiProvider // 'auto' | 'openai' | 'ollama'
// Handle 'auto' mode
if (provider === 'auto') {
// Check if Ollama is available
try {
const ollamaStatus = await checkOllamaHealth()
provider = ollamaStatus ? 'ollama' : 'openai'
} catch {
provider = 'openai' // Fallback to OpenAI
}
}
return getAIProvider(provider)
}
```
**Database Schema (from Decision 4):**
```prisma
model UserAISettings {
userId String @id
// Feature Flags (granular ON/OFF)
titleSuggestions Boolean @default(true)
semanticSearch Boolean @default(true)
paragraphRefactor Boolean @default(true)
memoryEcho Boolean @default(true)
// Configuration
memoryEchoFrequency String @default("daily") // 'daily' | 'weekly' | 'custom'
aiProvider String @default("auto") // 'auto' | 'openai' | 'ollama'
// Relation
user User @relation(fields: [userId], references: [id])
// Indexes for analytics
@@index([memoryEcho])
@@index([aiProvider])
@@index([memoryEchoFrequency])
}
```
---
## Epic 6: Language Detection Service
### Overview
Automatic language detection using TinyLD (62 languages including Persian). Hybrid approach: TinyLD for < 50 words, AI for ≥ 50 words.
**User Stories:** 2
**Estimated Complexity:** Medium
**Dependencies:** Decision 3 (Language Detection Strategy), TinyLD library
### Story 6.1: TinyLD Integration for Short Notes
**As a system, I want to detect note language efficiently for notes < 50 words using TinyLD, so that I can enable multilingual AI processing.**
**Acceptance Criteria:**
- **Given** a note with < 50 words
- **When** the note is saved or analyzed
- **Then** the system detects language using TinyLD
- **And** detection completes in < 10ms
- **And** the detected language is stored in `Note.language` field
- **And** confidence score is stored in `Note.languageConfidence` field
**Technical Requirements:**
- Library: `tinyld` (npm install tinyld)
- Service: `LanguageDetectionService` in `lib/ai/services/language-detection.service.ts`
- Supported Languages: 62 (including Persian/fa verified)
- Output Format: ISO 639-1 codes (fr, en, es, de, fa, etc.)
**Implementation:**
```typescript
// lib/ai/services/language-detection.service.ts
import { tinyld } from 'tinyld'
export class LanguageDetectionService {
private readonly MIN_WORDS_FOR_AI = 50
private readonly MIN_CONFIDENCE = 0.7
async detectLanguage(content: string): Promise<{
language: string // 'fr' | 'en' | 'es' | 'de' | 'fa' | 'unknown'
confidence: number // 0.0-1.0
method: 'tinyld' | 'ai' | 'manual'
}> {
const wordCount = content.split(/\s+/).length
// Short notes: TinyLD (fast, TypeScript native)
if (wordCount < this.MIN_WORDS_FOR_AI) {
const result = tinyld(content)
return {
language: this.mapToISO(result.language),
confidence: result.confidence || 0.8,
method: 'tinyld'
}
}
// Long notes: AI for better accuracy
const response = await generateText({
model: openai('gpt-4o-mini'), // or ollama/llama3.2
prompt: `Detect the language of this text. Respond ONLY with ISO 639-1 code (fr, en, es, de, fa):\n\n${content.substring(0, 500)}`
})
return {
language: response.text.toLowerCase().trim(),
confidence: 0.9,
method: 'ai'
}
}
private mapToISO(code: string): string {
const mapping = {
'fra': 'fr',
'eng': 'en',
'spa': 'es',
'deu': 'de',
'fas': 'fa',
'pes': 'fa', // Persian (Farsi)
'por': 'pt',
'ita': 'it',
'rus': 'ru',
'zho': 'zh'
}
return mapping[code] || code.substring(0, 2)
}
}
```
**Trigger Points:**
1. Note creation (on save)
2. Note update (on save)
3. Before AI processing (title generation, reformulation, etc.)
**Database Update:**
```typescript
// app/actions/notes.ts (extend existing createNote/updateNote)
export async function createNote(data: { title: string, content: string }) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
// Detect language
const languageService = new LanguageDetectionService()
const { language, languageConfidence } = await languageService.detectLanguage(data.content)
const note = await prisma.note.create({
data: {
...data,
userId: session.user.id,
language,
languageConfidence
}
})
return note
}
```
---
### Story 6.2: AI Fallback for Long Notes
**As a system, I want to use AI language detection for notes ≥ 50 words, so that I can achieve higher accuracy for longer content.**
**Acceptance Criteria:**
- **Given** a note with ≥ 50 words
- **When** the note is saved or analyzed
- **Then** the system detects language using AI (OpenAI or Ollama)
- **And** detection completes in < 500ms
- **And** the detected language is stored in `Note.language` field
- **And** confidence score is 0.9 (AI is more accurate)
**Technical Requirements:**
- Provider: Uses `getAIProvider()` factory
- Model: `gpt-4o-mini` (OpenAI) or `llama3.2` (Ollama)
- Prompt: Minimal (only language detection)
- Output: ISO 639-1 code only
**AI Prompt (from Story 6.1):**
```
Detect the language of this text. Respond ONLY with ISO 639-1 code (fr, en, es, de, fa):
{content (first 500 chars)}
```
**Performance Target:**
- TinyLD detection: ~8ms for < 50 words ✅
- AI detection: ~200-500ms for ≥ 50 words ✅
- Overall impact: Negligible for UX
---
## Implementation Phases
### Phase 1: Foundation (Week 1-2)
**Goal:** Database schema and base infrastructure
**Stories:**
- Epic 1-6: All Prisma migrations (3 new tables, extend Note model)
- Epic 6: Language Detection Service (TinyLD integration)
- Epic 5: AI Settings page + UserAISettings table
**Deliverables:**
- ✅ Prisma migrations created and applied
- ✅ `LanguageDetectionService` implemented
- ✅ `/settings/ai` page functional
- ✅ Base AI service layer structure created
---
### Phase 2: Infrastructure (Week 3-4)
**Goal:** Core services and AI provider integration
**Stories:**
- Epic 1: Title Suggestion Service
- Epic 2: Semantic Search Service (part 1 - embeddings)
- Epic 3: Paragraph Refactor Service
- Epic 4: Memory Echo Service (part 1 - background job)
**Deliverables:**
- ✅ All AI services implemented
- ✅ Provider factory extended for new services
- ✅ Server actions created for all features
- ✅ Integration tests passing
---
### Phase 3: AI Features (Week 5-9)
**Goal:** UI components and user-facing features
**Stories:**
- Epic 1: Title Suggestions UI (Stories 1.1, 1.2, 1.3)
- Epic 2: Semantic Search UI (Stories 2.1, 2.2, 2.3)
- Epic 3: Paragraph Reformulation UI (Stories 3.1, 3.2)
- Epic 4: Memory Echo UI (Stories 4.1, 4.2)
**Deliverables:**
- ✅ All AI components implemented
- ✅ Toast notifications working
- ✅ Modals and dialogs functional
- ✅ Feedback collection active
---
### Phase 4: Polish & Testing (Week 10-12)
**Goal:** Quality assurance and performance optimization
**Stories:**
- Epic 1-6: E2E Playwright tests
- Epic 1-6: Performance testing and optimization
- Epic 1-6: Multi-language testing (FR, EN, ES, DE, FA)
- Epic 1-6: Bug fixes and refinement
**Deliverables:**
- ✅ E2E test coverage for all AI features
- ✅ Performance targets met (search < 300ms, titles < 2s, Memory Echo < 100ms UI freeze)
- ✅ Multi-language verification complete
- ✅ Production deployment ready
---
## Dependencies & Critical Path
### Critical Path Implementation
```
Prisma Migrations → Language Detection Service → AI Settings Page
All AI Services
UI Components
Testing & Polish
```
### Parallel Development Opportunities
- **Week 1-2:** Language Detection + AI Settings (independent)
- **Week 3-4:** All AI services (can be developed in parallel)
- **Week 5-9:** UI components (can be developed in parallel per epic)
- **Week 10-12:** Testing (all features tested together)
### Cross-Epic Dependencies
- **All Epics → Epic 6 (Language Detection):** Must detect language before AI processing
- **All Epics → Epic 5 (AI Settings):** Must check feature flags before executing
- **Epic 2 (Semantic Search) → Existing Embeddings:** Reuses `Note.embedding` field
- **Epic 4 (Memory Echo) → Epic 2 (Semantic Search):** Uses cosine similarity from Epic 2
---
## Definition of Done
### Per Story
- [ ] Code implemented following `project-context.md` rules
- [ ] TypeScript strict mode compliance
- [ ] Server actions have `'use server'` directive
- [ ] Components have `'use client'` directive (if interactive)
- [ ] All imports use `@/` alias
- [ ] Error handling with `try/catch` and `console.error()`
- [ ] API responses follow `{success, data, error}` format
- [ ] `auth()` check in all server actions
- [ ] `revalidatePath('/')` after mutations
- [ ] E2E Playwright test written
- [ ] Manual testing completed
### Per Epic
- [ ] All stories completed
- [ ] Integration tests passing
- [ ] Performance targets met
- [ ] User acceptance criteria validated
- [ ] Documentation updated
### Phase 1 MVP AI
- [ ] All 6 epics completed
- [ ] Zero breaking changes to existing features
- [ ] All NFRs met (performance, security, privacy)
- [ ] Multi-language verified (FR, EN, ES, DE, FA)
- [ ] Production deployment ready
- [ ] User feedback collected and analyzed
---
*Generated: 2026-01-10*
*Author: Winston (Architect Agent) with Create Epics & Stories workflow*
*Based on: PRD Phase 1 MVP AI + UX Design Spec + Architecture (2784 lines)*
*Status: READY FOR IMPLEMENTATION*