diff --git a/.eslintrc-auto-import.json b/.eslintrc-auto-import.json index 008fd60..4b608c6 100644 --- a/.eslintrc-auto-import.json +++ b/.eslintrc-auto-import.json @@ -10,6 +10,7 @@ "Chat": true, "Dashboard": true, "MainLayout": true, + "Pomodoro": true, "Settings": true, "createRef": true, "forwardRef": true, diff --git a/.gitignore b/.gitignore index 3ef8e46..39ac83b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ target # Editor directories and files .vscode/* +.claude/ !.vscode/extensions.json .idea .DS_Store diff --git a/.project.md b/.project.md index 2b932ec..df7a8a4 100644 --- a/.project.md +++ b/.project.md @@ -29,15 +29,39 @@ const activities = await getActivities({ limit: 50 }) Always use auto-generated functions from `@/lib/client/apiClient` for type safety and automatic camelCase↔snake_case conversion. -### 2. Always Define Structured Request/Response Models +### 2. Always Use Relative Import for `api_handler` + +**CRITICAL: Handler modules MUST use relative import to avoid circular import issues.** + +```python +# ✅ CORRECT: Use relative import +from . import api_handler + +# ❌ WRONG: Causes circular import +from handlers import api_handler +from backend.handlers import api_handler +``` + +**Why:** The `handlers/__init__.py` imports all handler modules at the bottom. Using absolute import causes Python to reload the package while it's still initializing, resulting in `api_handler` being undefined or import failures. + +### 3. Always Define Structured Request/Response Models **CRITICAL RULE:** All handlers must use Pydantic models inheriting from `backend.models.base.BaseModel`. **NEVER use `Dict[str, Any]` as return type** - this prevents TypeScript type generation for the frontend. ```python -from backend.handlers import api_handler -from backend.models.base import BaseModel, TimedOperationResponse +from datetime import datetime +from typing import Optional + +from core.logger import get_logger +from models.base import BaseModel +from models.responses import TimedOperationResponse + +# ✅ CORRECT: Use relative import +from . import api_handler + +logger = get_logger(__name__) # ❌ WRONG - Dict prevents TypeScript type generation @api_handler() @@ -134,6 +158,480 @@ When frontend needs data:
``` +## Animation Guidelines + +**Philosophy: Fluid Productivity** + +- Animations should be purposeful, not decorative +- Professional yet delightful - enhance usability without distraction +- Motion language: Smooth, organic transitions +- Performance: Maintain 60fps, GPU-accelerated transforms only + +### Animation System Architecture + +**Files:** + +- `src/styles/animations.css` - Custom Tailwind animation utilities and keyframes +- `src/lib/animations/hooks.ts` - JavaScript animation hooks (counter, spring, scroll reveal) + +**Core Principles:** + +1. **Purposeful Motion** - Every animation serves a functional purpose (indicate state, guide attention, provide feedback) +2. **Consistent Timing** - Use unified duration/easing system (75ms/200ms/250ms/300ms/500ms) +3. **Respectful Performance** - Smooth 60fps, GPU-accelerated properties only (transform, opacity) +4. **Accessibility First** - Automatically respects `prefers-reduced-motion` via Tailwind CSS + +### Technology Stack + +**Primary: Tailwind CSS Animations** + +- Use Tailwind's built-in animation utilities (`animate-in`, `fade-in`, `slide-in-from-*`) +- Custom utilities defined in `animations.css` +- Automatic `prefers-reduced-motion` support +- Better performance than JavaScript-based animations + +**Secondary: JavaScript Hooks** (for complex animations) + +- `useCounterAnimation` - Numeric counter with requestAnimationFrame +- `useSpringValue` - Spring physics for smooth progress indicators +- `useScrollReveal` - Intersection Observer for scroll-triggered animations +- `useHover` - Hover state detection + +```typescript +import { cn } from '@/lib/utils' + +// ✅ CORRECT: Use Tailwind animation classes +
+ {items.map((item, i) => ( +
+ {item.content} +
+ ))} +
+``` + +### Standard Animation Patterns + +#### 1. Page Transitions + +**Use in:** All main view components (Dashboard, Activity, Chat, AITodos, etc.) + +```typescript +// Implemented in MainLayout.tsx +
+ +
+``` + +**Custom class:** `animate-page-enter` - Fade in with subtle slide up (250ms ease-out) + +--- + +#### 2. Tab/View Switching + +**Use in:** Tab content, view mode switches (cards vs calendar, stats vs history) + +```typescript +// Example from Pomodoro.tsx and AITodos.tsx + + {/* Content */} + + + + {/* Content */} + +``` + +**Effect:** Smooth 300ms fade + slide when switching between tabs + +--- + +#### 3. List Stagger Animations + +**Use in:** Any list or grid of items (todos, activities, messages, sessions, cards) + +```typescript +// Example from Activity.tsx, TodoCardsView.tsx, Pomodoro.tsx +
+ {items.map((item, index) => ( +
+ +
+ ))} +
+``` + +**Key points:** + +- **Delay:** 30-50ms per item (30ms for dense lists, 50ms for cards) +- **Duration:** 200ms +- **CRITICAL:** Always set `animationFillMode: 'backwards'` to prevent content flash +- Static alternative: Use `.stagger-1` through `.stagger-10` classes (50ms increments) + +--- + +#### 4. Card Grid Animations + +**Use in:** Dashboard metrics, statistics overview, knowledge cards + +```typescript +// Example from PomodoroStatsOverview.tsx +
+ {stats.map((stat, index) => ( +
+ + {/* Card content */} + +
+ ))} +
+``` + +**Effect:** Cards appear sequentially with 50ms stagger + hover lift on interaction + +--- + +#### 5. Chart/Graph Entrance + +**Use in:** Weekly focus chart, trend charts, progress bars + +```typescript +// Example from WeeklyFocusChart.tsx + + +
+ {data.map((item, index) => ( +
+ {/* Chart bar item */} +
+ ))} +
+
+
+``` + +**Effect:** Chart container fades in, then bars slide from left sequentially + +--- + +#### 6. Counter Animations + +**Use in:** Numeric metrics, statistics, dashboards + +```typescript +// Example from Dashboard.tsx, metric-card.tsx +import { useCounterAnimation } from '@/lib/animations/hooks' + +const animatedValue = useCounterAnimation(targetValue, 1000, 0) +return
{animatedValue.toLocaleString()}
+``` + +**Parameters:** + +- `target` - Target number +- `duration` - Animation duration in ms (default: 1000) +- `decimals` - Decimal places (default: 0) + +**Implementation:** Uses `requestAnimationFrame` with cubic ease-out + +--- + +#### 7. Spring Physics Animations + +**Use in:** Circular progress, smooth value transitions + +```typescript +// Example from CircularProgress.tsx +import { useSpringValue } from '@/lib/animations/hooks' + +const animatedProgress = useSpringValue(progress, 300, 30) +const offset = circumference - (animatedProgress / 100) * circumference + + +``` + +**Parameters:** + +- `target` - Target value +- `stiffness` - Spring stiffness (default: 300, higher = faster) +- `damping` - Spring damping (default: 30, higher = less bouncy) + +**Implementation:** Uses `requestAnimationFrame` with spring physics + +--- + +#### 8. Button Press Feedback + +**Automatic:** All Button components have press feedback via `button-press` class + +```typescript +// Already applied to all Button components + +``` + +**Effect:** Scale down to 95% on active state (75ms duration) + +--- + +#### 9. Card Hover Effects + +**Use in:** Interactive cards, knowledge cards, metric cards, session items + +```typescript +// Example from KnowledgeCard.tsx, PomodoroStatsOverview.tsx + + {/* Card content */} + +``` + +**Effect:** Subtle lift (-4px translateY) + enhanced shadow on hover (200ms ease-out) + +--- + +#### 10. Directional Message Entrance + +**Use in:** Chat messages, notifications + +```typescript +// Example from MessageItem.tsx +
+ {message.content} +
+``` + +**Available classes:** + +- `slide-in-left` - Slide from left with fade (200ms) - for AI messages +- `slide-in-right` - Slide from right with fade (200ms) - for user messages + +--- + +#### 11. Icon Bounce + +**Use in:** Favorite stars, interactive icons, status indicators + +```typescript +// Example from KnowledgeCard.tsx + +``` + +**Effect:** Bounce animation on mount with scale transformation (300ms) + +### Available Animation Classes + +**From animations.css:** + +| Class | Effect | Duration | Use Case | +| ----------------------------- | ----------------------- | -------- | ----------------- | +| `animate-page-enter` | Fade + slide up | 250ms | Page transitions | +| `card-hover` | Lift + shadow on hover | 200ms | Interactive cards | +| `button-press` | Scale down on active | 75ms | All buttons | +| `slide-in-left` | Slide from left + fade | 200ms | AI messages | +| `slide-in-right` | Slide from right + fade | 200ms | User messages | +| `icon-bounce` | Bounce scale | 300ms | Icons, stars | +| `.stagger-1` to `.stagger-10` | Delays 50-500ms | - | Static lists | + +**From Tailwind built-ins:** + +| Class | Effect | Use Case | +| ------------------------------- | ----------------- | ------------------------ | +| `animate-in fade-in` | Fade in | General entrances | +| `slide-in-from-bottom-2` | Slide from bottom | List items, cards | +| `slide-in-from-left-2` | Slide from left | Chart bars | +| `duration-200` / `duration-300` | Custom duration | Combined with animate-in | + +### Performance Best Practices + +**DO:** + +- ✅ Use `transform` properties (translateX/Y, scale, rotate) - GPU accelerated +- ✅ Use `opacity` - GPU accelerated +- ✅ Use Tailwind's built-in `animate-in` utilities +- ✅ Set `animationFillMode: 'backwards'` for ALL stagger animations (prevents flash) +- ✅ Use `useSpringValue` for smooth progress indicators +- ✅ Use `useCounterAnimation` for numeric counters +- ✅ Keep stagger delays between 30-50ms per item +- ✅ Apply `card-hover` to interactive cards + +**DON'T:** + +- ❌ Animate `width`, `height`, `top`, `left` - triggers layout reflow +- ❌ Animate colors frequently - less performant +- ❌ Chain too many animations simultaneously (max 3-4 per view) +- ❌ Use overly long durations (keep under 400ms for most animations) +- ❌ Forget `animationFillMode: 'backwards'` on stagger animations (causes content flash) + +### Timing Standards + +**Standard durations:** + +| Duration | Use Case | Example | +| -------- | ----------------------------------------------------- | ------------------------------ | +| `75ms` | Button presses, instant feedback | `button-press` class | +| `200ms` | List items, card entrances, hover effects, chart bars | Most stagger animations | +| `250ms` | Page transitions | `animate-page-enter` | +| `300ms` | Tab switches, view mode changes, spring animations | Tab content, circular progress | +| `500ms` | Complex state changes, modals | Rarely used | + +**Stagger delays:** + +- **Dense lists** (sessions, messages): 30ms per item +- **Card grids** (stats, todos): 50ms per item +- **Chart bars**: 40ms per item +- **Maximum total stagger**: Keep under 500ms (max 10 items at 50ms) + +**Easing standards:** + +- `ease-out` - Standard for all entrances, slides, fades (default) +- `cubic-bezier(0.4, 0, 0.2, 1)` - Smooth deceleration (Tailwind default) + +### Accessibility + +**Automatic Support:** Tailwind CSS automatically respects `prefers-reduced-motion` media query. + +```css +/* Automatically applied in animations.css */ +@media (prefers-reduced-motion: reduce) { + .animate-page-enter, + .slide-in-left, + .slide-in-right, + .icon-bounce, + [class*='animate-in'] { + animation: none !important; + animation-delay: 0 !important; + } + + * { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} +``` + +**Result:** + +- All animations disabled when user prefers reduced motion +- No manual checking needed in components +- Functionality remains intact + +### Common Patterns Summary + +**Quick Reference:** + +```typescript +// 1. Page entrance (MainLayout) +
+ +// 2. Tab switching (Pomodoro, AITodos) + + +// 3. List stagger (Activity, TodoCards, Sessions) +{items.map((item, i) => ( +
+ +// 4. Card grid stagger (PomodoroStats, Dashboard) +{cards.map((card, i) => ( +
+ + +// 5. Chart with staggered bars (WeeklyFocusChart) + + {data.map((item, i) => ( +
+ +// 6. Chat messages (MessageItem) +
+ +// 7. Counter animation (Dashboard metrics) +const value = useCounterAnimation(target, 1000, 0) +{value.toLocaleString()} + +// 8. Spring physics (CircularProgress) +const animated = useSpringValue(progress, 300, 30) + +``` + +### Adding New Animation Patterns + +**Process:** + +1. **Define in `animations.css`** - Add to `@layer utilities` block +2. **Use @keyframes** - Define custom animation sequence +3. **Add to table above** - Document the new class +4. **Test accessibility** - Verify `prefers-reduced-motion` works +5. **Update this guide** - Add usage example + +**Template:** + +```css +/* src/styles/animations.css */ +@layer utilities { + .my-new-animation { + animation: myNewAnimation 0.3s ease-out; + } +} + +@keyframes myNewAnimation { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* Add to reduced motion section */ +@media (prefers-reduced-motion: reduce) { + .my-new-animation { + animation: none !important; + } +} +``` + +### Animation Checklist + +**Before committing animated components:** + +- [ ] Used classes from `animations.css` table above +- [ ] Set `animationFillMode: 'backwards'` for stagger animations +- [ ] Animation runs at 60fps (check DevTools Performance tab) +- [ ] Uses only GPU-accelerated properties (transform, opacity) +- [ ] Duration appropriate for context (see timing standards table) +- [ ] Stagger delay appropriate for item type (30-50ms) +- [ ] Applied `card-hover` to interactive cards +- [ ] Animation enhances UX, not just decoration +- [ ] Works correctly with `prefers-reduced-motion` (auto-handled by Tailwind) + ## Project Overview **Tech Stack:** React 19 + TypeScript 5 + Vite 6 + Tailwind CSS 4 + Python 3.14+ (PyTauri 0.8) + Tauri 2.x + SQLite + Zustand 5 @@ -154,6 +652,15 @@ When frontend needs data: **Data Flow:** RawRecords (60s memory) → Events (LLM) → Activities (10min aggregation) → Tasks (AI-generated) +**Pomodoro Mode:** + +- **Core Principle**: Pomodoro mode ONLY controls whether perception layer (keyboard/mouse/screenshots) is running +- **Idle Mode (Default)**: Perception layer is stopped, no data captured +- **Active Mode (Pomodoro)**: Perception layer is running, captures user activity +- **No Configuration**: Pomodoro has NO system configuration parameters (e.g., default duration, behavior settings) +- **User-Controlled**: Duration is specified per session when starting, not a global config +- **Behavior Unchanged**: Capture behavior (smart capture, deduplication, etc.) follows normal settings and is NOT modified by Pomodoro mode + ## Development Commands ```bash @@ -182,23 +689,48 @@ pnpm sign-macos # Code signing (after bundle) ### Adding API Handler +**CRITICAL: Always use relative import `from . import api_handler` in handler modules to avoid circular import issues.** + ```python # 1. backend/handlers/my_feature.py -from backend.handlers import api_handler -from backend.models.base import BaseModel +from datetime import datetime + +from core.coordinator import get_coordinator +from core.logger import get_logger +from models.base import BaseModel +from models.responses import TimedOperationResponse + +# ✅ CORRECT: Use relative import to avoid circular imports +from . import api_handler + +logger = get_logger(__name__) + class MyRequest(BaseModel): user_input: str + @api_handler(body=MyRequest, method="POST", path="/endpoint", tags=["module"]) -async def my_handler(body: MyRequest) -> dict: - return {"data": body.user_input} +async def my_handler(body: MyRequest) -> TimedOperationResponse: + return TimedOperationResponse( + success=True, + message="Operation completed", + timestamp=datetime.now().isoformat() + ) # 2. Import in backend/handlers/__init__.py -# 3. Run: pnpm setup-backend +# 3. Run: pnpm tauri:dev:gen-ts (to regenerate TypeScript bindings) # 4. Use: import { myHandler } from '@/lib/client/apiClient' ``` +**Why relative import is required:** + +- `from . import api_handler` ✅ Correct - imports from current package (`handlers`) +- `from handlers import api_handler` ❌ Wrong - causes circular import because `handlers/__init__.py` is importing your module +- `from backend.handlers import api_handler` ❌ Wrong - same circular import issue + +The `handlers/__init__.py` file imports all handler modules at the bottom (line 207-218). If your handler uses absolute import, Python will try to reload the `handlers` package while it's still being initialized, causing import failures. + ### Adding i18n ```typescript diff --git a/README.md b/README.md index 4e7cb7b..37d2c41 100644 --- a/README.md +++ b/README.md @@ -16,151 +16,249 @@ A locally deployed AI desktop assistant that understands your activity stream, u --- -## 🌟 Why iDO? +## ✨ Key Features -- **💻 Cross-Platform**: Works seamlessly on Windows and macOS +- **💻 Cross-Platform**: Works seamlessly on macOS, Windows, and Linux - **🔒 Privacy-First**: All data processing happens locally on your device -- **🤖 AI-Powered**: Intelligent activity summarization and context-aware task recommendations -- **🎯 Context-Aware**: Understands your work patterns and suggests next steps -- **🌍 Flexible**: Bring your own LLM provider (OpenAI, Claude, local models) -- **📊 Comprehensive**: Activity tracking, knowledge base, task management, and insights—all in one place +- **🍅 Pomodoro Focus**: Intelligent timer with task linking, focus scoring, and session review +- **📚 Knowledge Capture**: AI turns activities into searchable knowledge cards with smart merge +- **✅ Smart Todos**: AI-generated tasks with scheduling, priorities, and Pomodoro linking +- **📓 Daily Diary**: AI-generated work summaries to reflect on your progress +- **💬 Contextual Chat**: Ask questions about your activities with grounded answers -### Product Demos +--- -#### Activity +## 📸 Feature Demos -![Activity demo](assets/activity.gif) +### Knowledge -Auto-grouped activity timeline with concise summaries so you can review sessions fast. +![Knowledge demo](assets/knowledge.gif) -#### Knowledge +AI turns your daily activities into searchable knowledge cards. Find what you learned, organize with categories, and use Smart Merge to combine duplicates. -![Knowledge demo](assets/knowledge.gif) +**Features**: + +- Full-text search across all cards +- Category/keyword filtering +- Smart duplicate detection and merging +- Create manual notes + +### Todos + +![Todos demo](assets/todo.gif) + +AI-generated tasks from your context. Schedule on calendar, set priorities, and link to Pomodoro sessions. Drag to calendar to schedule. + +**Features**: + +- Manual creation supported +- Calendar scheduling with start/end times +- Recurrence rules (daily, weekly, etc.) +- Send to Chat for AI execution -Turns daily activity into searchable knowledge cards for long-term recall. +### Pomodoro -#### To-do +Focus Mode: Start a Pomodoro session to capture and analyze your focused work. Configure work/break durations and track progress. -![To-do demo](assets/todo.gif) +**What it does**: Focus Mode with intelligent Pomodoro timer for capturing and analyzing your focused work -Converts context into actionable tasks and lets you manage status and priority. +**Features**: -#### Diary +- 4 preset modes: Classic (25/5), Deep (50/10), Quick (15/3), Focus (90/15) +- Task association with AI-generated todos +- Real-time countdown with circular progress +- Phase notifications (work/break transitions) + +### Pomodoro Review + +Review your focus sessions and track your productivity. View activity timelines, AI-powered focus analysis, and weekly statistics. + +**Features**: + +- Session history with duration and timestamps +- AI focus quality evaluation (strengths, weaknesses, suggestions) +- Work type analysis (deep work, distractions, focus streaks) +- Weekly focus goal tracking + +### Diary ![Diary demo](assets/diary.gif) -Builds a personal work diary with editable summaries and highlights. +AI-generated daily work summaries. Scroll through history, select dates to generate, and edit summaries to reflect on your progress. + +**Features**: -#### Chat +- Daily automated summaries +- Select specific dates to generate +- Editable content +- Scrollable history with load more + +### Chat ![Chat demo](assets/chat.gif) -Ask questions about your history and get grounded answers from your activity stream. +Conversational AI about your activities with streaming responses. Ask questions, analyze images, and get grounded answers from your data. + +**Features**: -#### Dashboard +- Streaming responses for real-time feedback +- Image drag-and-drop support (PNG, JPG, GIF) +- Model selection per conversation +- Send todos/knowledge from other pages + +### Dashboard ![Dashboard demo](assets/dashboard.gif) -At-a-glance insights into focus, time use, and trends across days. +View Token usage and Agent task statistics. Track token consumption, API calls, and costs across all your models. + +**What it does**: View Token usage and Agent task statistics + +**Metrics**: + +- Total tokens processed and API calls made +- Total cost by model with currency display +- Usage trends over week/month/year +- Per-model price tracking (input/output tokens) --- -## 📐 How It Works +## 🏗️ Architecture
- architecture + Architecture
-**iDO works in three intelligent layers**: +iDO works in three intelligent layers: + +1. **Perception Layer** - Monitors keyboard, mouse, screen activity in real-time +2. **Processing Layer** - AI filters noise and organizes meaningful activities +3. **Consumption Layer** - Delivers insights, tasks, and context when you need them -1. **Capture** - Monitors your screen and interactions silently in the background -2. **Process** - AI filters out noise and organizes meaningful activities -3. **Deliver** - Presents insights, tasks, and context when you need them +### Tech Stack -All processing happens locally on your device for maximum privacy. +**Frontend:** React 19 + TypeScript 5 + Vite 6 + Tailwind CSS 4 + shadcn/ui + Zustand 5 + TanStack React Query 5 -📖 **[Learn more about the architecture →](docs/developers/architecture/README.md)** +**Backend:** Python 3.14+ + Tauri 2.x + PyTauri 0.8 + FastAPI + SQLite + +**AI/ML:** OpenAI-compatible APIs + smolagents framework + LLM-powered summarization --- ## 🚀 Quick Start -**[Download the latest release →](https://github.com/UbiquantAI/iDO/releases/latest)** +### Prerequisites -Follow the installation guide to get started: +- [Node.js 22+](https://nodejs.org/) +- [Python 3.14+](https://www.python.org/) +- [uv](https://docs.astral.sh/uv/) - Fast Python package manager +- [pnpm](https://pnpm.io/) - Fast package manager -- 📖 **[Installation Guide →](docs/user-guide/installation.md)** -- 🎯 **[Features Overview →](docs/user-guide/features.md)** -- ❓ **[FAQ →](docs/user-guide/faq.md)** +### Installation -**Want to contribute?** Check out the **[Developer Guide →](docs/developers/README.md)** +```bash +# Clone the repository +git clone https://github.com/UbiquantAI/iDO.git +cd iDO ---- +# Install dependencies +pnpm setup -## 🎯 Key Features +# Start development with type generation +pnpm tauri:dev:gen-ts +``` -### Privacy-First Design +### Available Commands -- ✅ All data processing happens on your device -- ✅ No mandatory cloud uploads -- ✅ User-controlled LLM provider (bring your own API key) -- ✅ Open source and auditable +| Command | Description | +| ----------------------- | ----------------------------------------- | +| `pnpm dev` | Frontend only | +| `pnpm tauri:dev:gen-ts` | Full app with TS generation (recommended) | +| `pnpm format` | Format code | +| `pnpm check-i18n` | Validate translations | +| `uv run ty check` | Backend type checking | +| `pnpm tsc` | Frontend type checking | +| `pnpm bundle` | Production build (macOS/Linux) | +| `pnpm bundle:win` | Production build (Windows) | -### Intelligent Activity Tracking +--- -- 📊 Automatic activity detection and grouping -- 🖼️ Smart screenshot deduplication -- 🧠 LLM-powered summarization -- 🔍 Searchable activity timeline +## 🎯 Core Features -### AI Task Recommendations +### Perception Layer -- 🤖 Plugin-based agent system -- ✅ Context-aware task suggestions -- 📝 Priority and status tracking -- 🔄 Continuous learning from your patterns +- **Smart Capture**: Only records when user is active +- **Screenshot Deduplication**: Image hashing avoids redundant captures +- **Activity Detection**: Classifies "operation" vs "browsing" behavior +- **Window Tracking**: Knows which app you're using ---- +### Processing Layer -## 📖 Documentation +- **Event Extraction**: LLM extracts meaningful actions from screenshots +- **Activity Aggregation**: Groups related actions into activities (10min intervals) +- **Smart Filtering**: AI separates signal from noise + +### Consumption Layer -### 👥 For Users +- **AI Task Generation**: Automatically creates todos from your activities +- **Knowledge Capture**: Long-term memory from daily work +- **Daily Diaries**: AI-generated work summaries +- **Pomodoro Timer**: Focus sessions with task linking and focus scoring -| Guide | Description | -| --------------------------------------------------------- | ------------------------------ | -| **[Installation](docs/user-guide/installation.md)** | Download and install iDO | -| **[Features](docs/user-guide/features.md)** | Learn about iDO's capabilities | -| **[FAQ](docs/user-guide/faq.md)** | Frequently asked questions | -| **[Troubleshooting](docs/user-guide/troubleshooting.md)** | Fix common issues | +### Knowledge Management -📚 **[Complete User Guide →](docs/user-guide/README.md)** +- **AI-Powered Search**: Find anything you've done +- **Knowledge Merge**: Smart duplicate detection and merging +- **Favorites & Categories**: Organize your knowledge base -### 💻 For Developers +### Pomodoro Focus Mode -Want to contribute or build on top of iDO? +- **Configurable Timer**: Work duration, break duration, rounds +- **Task Association**: Link sessions to AI-generated todos +- **Focus Scoring**: AI evaluates each session's focus quality +- **Session Review**: Detailed breakdowns with activity timelines +- **Progress Tracking**: Weekly focus statistics and trends -📚 **[Complete Developer Documentation →](docs/developers/README.md)** +--- -Quick links: [Setup](docs/developers/getting-started/README.md) • [Architecture](docs/developers/architecture/README.md) • [API Reference](docs/developers/reference/) • [Deployment](docs/developers/deployment/) +## 📁 Project Structure + +``` +ido/ +├── src/ # React frontend +│ ├── views/ # Page components +│ ├── components/ # UI components +│ ├── lib/ +│ │ ├── stores/ # Zustand stores +│ │ ├── client/ # Auto-generated API client +│ │ └── types/ # TypeScript types +│ ├── hooks/ # Custom React hooks +│ └── locales/ # i18n translations +├── backend/ # Python backend +│ ├── handlers/ # API handlers +│ ├── core/ # Core systems +│ ├── perception/ # Perception layer +│ ├── processing/ # Processing pipeline +│ ├── agents/ # AI agents +│ └── llm/ # LLM integration +├── src-tauri/ # Tauri app +└── scripts/ # Build scripts +``` --- -### 📚 Documentation Hub +## 📖 Documentation -**[docs/README.md](docs/README.md)** - Central documentation hub with quick navigation +- 📚 [User Guide](docs/user-guide/README.md) +- 📚 [Developer Guide](docs/developers/README.md) +- 📚 [API Reference](docs/developers/reference/) +- 📚 [Architecture](docs/developers/architecture/README.md) --- ## 🤝 Contributing -We welcome contributions! Whether you want to: - -- 🐛 Report bugs or suggest features -- 📖 Improve documentation -- 💻 Submit code changes -- 🌍 Add translations - -**[Read the Contributing Guide →](docs/developers/getting-started/development-workflow.md)** to learn how to get started. +We welcome contributions! Check out the [Contributing Guide](docs/developers/getting-started/development-workflow.md) to get started. --- @@ -176,13 +274,12 @@ This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENS - Powered by [PyTauri](https://pytauri.github.io/) - Python ↔ Rust bridge - UI components from [shadcn/ui](https://ui.shadcn.com/) - Icons from [Lucide](https://lucide.dev/) +- Agent framework [smolagents](https://github.com/huggingface/smolagents) --- ## 👥 Maintainers -We are responsible for maintaining this project. -
IcyFeather233 @@ -194,8 +291,6 @@ We are responsible for maintaining this project. ## 🙌 Contributors -We sincerely thank the following people for their active contributions. -
TexasOct @@ -209,7 +304,7 @@ We sincerely thank the following people for their active contributions.
-**[📖 Documentation Hub](docs/README.md)** • **[👥 User Guide](docs/user-guide/README.md)** • **[💻 Developer Docs](docs/developers/README.md)** • **[🤝 Contribute](docs/developers/getting-started/development-workflow.md)** +**[📖 Documentation](docs/README.md)** • **[👥 User Guide](docs/user-guide/README.md)** • **[💻 Developer Guide](docs/developers/README.md)** Made with ❤️ by the iDO team diff --git a/README.zh-CN.md b/README.zh-CN.md index df6ac57..f3fa7dd 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -16,151 +16,245 @@ --- -## 🌟 为什么选择 iDO? +## ✨ 核心特性 -- **💻 跨平台支持**:在 Windows 和 macOS 上无缝运行 +- **💻 跨平台支持**:在 macOS、Windows 和 Linux 上无缝运行 - **🔒 隐私优先**:所有数据处理都在你的设备本地完成 -- **🤖 AI 驱动**:智能活动总结和上下文感知的任务推荐 -- **🎯 懂你所需**:理解你的工作模式并建议下一步行动 -- **🌍 灵活选择**:自带 LLM 提供商(OpenAI、Claude、本地模型) -- **📊 全面覆盖**:活动跟踪、知识库、任务管理和洞察分析——一站式解决 +- **🍅 番茄钟专注**:智能计时器,支持任务关联、专注评分和会话回顾 +- **📚 知识捕获**:AI 将活动转化为可搜索的知识卡片,支持智能合并 +- **✅ 智能待办**:AI 生成任务,支持日程安排、优先级设置和番茄钟关联 +- **📓 每日日记**:AI 生成工作摘要,帮助你反思进度 +- **💬 上下文对话**:基于你的活动数据回答问题 -### 功能演示 +--- -#### 活动 +## 📸 功能演示 -![活动演示](assets/activity.gif) +### 知识 -自动聚合活动时间线并生成简明总结,快速回顾每个工作片段。 +![知识演示](assets/knowledge.gif) -#### 知识 +AI 将你的日常活动转化为可搜索的知识卡片。查找你学到的东西,通过分类组织,使用智能合并来整合重复内容。 -![知识演示](assets/knowledge.gif) +**功能**: -把日常活动转成可搜索的知识卡片,长期回忆更轻松。 +- 全文搜索所有卡片 +- 分类/关键词过滤 +- 智能重复检测和合并 +- 创建手动笔记 -#### 待办 +### 待办 ![待办演示](assets/todo.gif) -从上下文中生成可执行任务,并支持状态与优先级管理。 +从你的上下文中生成 AI 任务。在日历上安排,设置优先级,关联番茄钟会话。拖放到日历进行安排。 + +**功能**: + +- 支持手动创建 +- 日程安排(开始/结束时间) +- 循环规则(每日、每周等) +- 发送到 Chat 进行 AI 执行 + +### 番茄钟 + +专注模式:开启番茄钟会话以捕获并分析您的专注工作。可配置工作/休息时长并追踪进度。 + +**功能**: + +- 4种模式:经典 (25/5)、深度 (50/10)、快速 (15/3)、专注 (90/15) +- 与 AI 生成的任务关联 +- 实时倒计时与环形进度 +- 阶段通知(工作/休息切换) -#### 日记 +### 番茄钟回顾 + +回顾你的专注会话并追踪生产力。活动时间线、AI 专注分析和每周统计数据。 + +**功能**: + +- 带时间戳的会话历史 +- AI 专注质量评估(优势、劣势、建议) +- 工作类型分析(深度工作、分心、专注 streaks) +- 每周专注目标追踪 + +### 日记 ![日记演示](assets/diary.gif) -自动生成个人工作日记,摘要可编辑、重点更清晰。 +AI 生成的每日工作摘要。浏览历史,选择日期生成,编辑摘要以反思你的进度。 -#### 聊天 +**功能**: + +- 每日自动摘要 +- 选择特定日期生成 +- 可编辑内容 +- 滚动历史(加载更多) + +### 聊天 ![聊天演示](assets/chat.gif) -围绕历史活动提问,获得有依据的上下文回答。 +关于你的活动的对话式 AI,支持流式响应。提出问题、分析图片,从你的数据中获得有依据的回答。 + +**功能**: + +- 流式响应,实时反馈 +- 图片拖放支持(PNG、JPG、GIF) +- 每个对话可选择模型 +- 从其他页面发送待办/知识 -#### 仪表盘 +### 仪表盘 ![仪表盘演示](assets/dashboard.gif) -一眼查看专注度、用时与趋势,掌握整体节奏。 +查看 Token 使用量和 Agent 任务统计。追踪 Token 消耗、API 调用和成本,查看所有模型的交互趋势图表。 + +**指标**: + +- Token 处理总量和 API 调用次数 +- 按模型显示成本,支持多币种 +- 周/月/年使用趋势 +- 按模型价格追踪(输入/输出 Token) --- -## 📐 工作原理 +## 🏗️ 系统架构
- architecture + 架构
-**iDO 分三个智能层级工作**: +iDO 分三个智能层级工作: + +1. **感知层** - 实时监控键盘、鼠标、屏幕活动 +2. **处理层** - AI 过滤噪音并组织有意义的活动 +3. **呈现层** - 在你需要时提供洞察、任务和上下文 -1. **捕获** - 在后台静默监控你的屏幕和交互 -2. **处理** - AI 过滤噪音并组织有意义的活动 -3. **呈现** - 在你需要时提供洞察、任务和上下文 +### 技术栈 -所有处理都在你的设备本地完成,最大限度保护隐私。 +**前端:** React 19 + TypeScript 5 + Vite 6 + Tailwind CSS 4 + shadcn/ui + Zustand 5 + TanStack React Query 5 -📖 **[了解更多架构细节 →](docs/developers/architecture/README.md)** +**后端:** Python 3.14+ + Tauri 2.x + PyTauri 0.8 + FastAPI + SQLite + +**AI/ML:** OpenAI 兼容 API + smolagents 框架 + LLM 驱动的总结 --- ## 🚀 快速开始 -**[下载最新版本 →](https://github.com/UbiquantAI/iDO/releases/latest)** +### 环境要求 -按照安装指南快速上手: +- [Node.js 22+](https://nodejs.org/) +- [Python 3.14+](https://www.python.org/) +- [uv](https://docs.astral.sh/uv/) - 快速 Python 包管理器 +- [pnpm](https://pnpm.io/) - 快速包管理器 -- 📖 **[安装指南 →](docs/user-guide/installation.md)** -- 🎯 **[功能概览 →](docs/user-guide/features.md)** -- ❓ **[常见问题 →](docs/user-guide/faq.md)** +### 安装 -**想要贡献代码?** 查看 **[开发者指南 →](docs/developers/README.md)** +```bash +# 克隆仓库 +git clone https://github.com/UbiquantAI/iDO.git +cd iDO ---- +# 安装依赖 +pnpm setup -## 🎯 核心功能 +# 启动开发(推荐,带类型生成) +pnpm tauri:dev:gen-ts +``` -### 隐私优先设计 +### 可用命令 -- ✅ 所有数据处理都在你的设备上进行 -- ✅ 无强制云上传 -- ✅ 用户控制 LLM 提供商(使用自己的 API 密钥) -- ✅ 开源且可审计 +| 命令 | 描述 | +| ----------------------- | ----------------------- | +| `pnpm dev` | 仅前端 | +| `pnpm tauri:dev:gen-ts` | 完整应用(推荐) | +| `pnpm format` | 格式化代码 | +| `pnpm check-i18n` | 验证翻译 | +| `uv run ty check` | 后端类型检查 | +| `pnpm tsc` | 前端类型检查 | +| `pnpm bundle` | 生产构建(macOS/Linux) | +| `pnpm bundle:win` | 生产构建(Windows) | -### 智能活动跟踪 +--- -- 📊 自动活动检测和分组 -- 🖼️ 智能截图去重 -- 🧠 LLM 驱动的总结 -- 🔍 可搜索的活动时间线 +## 🎯 核心功能 -### AI 任务推荐 +### 感知层 -- 🤖 基于插件的 Agent 系统 -- ✅ 上下文感知的任务建议 -- 📝 优先级和状态跟踪 -- 🔄 从你的使用模式中持续学习 +- **智能捕获**:仅在用户活跃时记录 +- **截图去重**:图像哈希避免冗余捕获 +- **活动检测**:区分"操作"与"浏览"行为 +- **窗口跟踪**:了解你正在使用的应用 ---- +### 处理层 -## 📖 文档 +- **事件提取**:LLM 从截图中提取有意义的操作 +- **活动聚合**:将相关操作分组为活动(10分钟间隔) +- **智能过滤**:AI 分离信号与噪音 + +### 呈现层 -### 👥 普通用户 +- **AI 任务生成**:从活动中自动创建待办 +- **知识捕获**:从日常工作中建立长期记忆 +- **每日日记**:AI 生成的工作摘要 +- **番茄钟计时器**:带任务关联和专注评分的专注会话 -| 指南 | 描述 | -| -------------------------------------------------- | --------------- | -| **[安装](docs/user-guide/installation.md)** | 下载和安装 iDO | -| **[功能](docs/user-guide/features.md)** | 了解 iDO 的功能 | -| **[常见问题](docs/user-guide/faq.md)** | 常见问题解答 | -| **[故障排除](docs/user-guide/troubleshooting.md)** | 解决常见问题 | +### 知识管理 -📚 **[完整用户指南 →](docs/user-guide/README.md)** +- **AI 驱动搜索**:查找你做过的任何事 +- **知识合并**:智能重复检测和合并 +- **收藏与分类**:组织你的知识库 -### 💻 开发者 +### 番茄钟专注模式 -想要贡献或基于 iDO 进行开发? +- **可配置计时器**:工作时长、休息时长、轮数 +- **任务关联**:将会话与 AI 生成的待办关联 +- **专注评分**:AI 评估每次会话的专注质量 +- **会话回顾**:包含活动时间线的详细分解 +- **进度追踪**:周度专注统计和趋势 -📚 **[完整开发者文档 →](docs/developers/README.md)** +--- -快速链接:[环境搭建](docs/developers/getting-started/README.md) • [架构设计](docs/developers/architecture/README.md) • [API 参考](docs/developers/reference/) • [部署指南](docs/developers/deployment/) +## 📁 项目结构 + +``` +ido/ +├── src/ # React 前端 +│ ├── views/ # 页面组件 +│ ├── components/ # UI 组件 +│ ├── lib/ +│ │ ├── stores/ # Zustand 状态管理 +│ │ ├── client/ # 自动生成的 API 客户端 +│ │ └── types/ # TypeScript 类型 +│ ├── hooks/ # 自定义 React Hooks +│ └── locales/ # i18n 翻译 +├── backend/ # Python 后端 +│ ├── handlers/ # API 处理器 +│ ├── core/ # 核心系统 +│ ├── perception/ # 感知层 +│ ├── processing/ # 处理管道 +│ ├── agents/ # AI 代理 +│ └── llm/ # LLM 集成 +├── src-tauri/ # Tauri 应用 +└── scripts/ # 构建脚本 +``` --- -### 📚 文档中心 +## 📖 文档 -**[docs/README.md](docs/README.md)** - 中央文档中心,快速导航 +- 📚 [用户指南](docs/user-guide/README.md) +- 📚 [开发者指南](docs/developers/README.md) +- 📚 [API 参考](docs/developers/reference/) +- 📚 [架构设计](docs/developers/architecture/README.md) --- ## 🤝 贡献 -我们欢迎各种形式的贡献!无论你想要: - -- 🐛 报告 bug 或提出功能建议 -- 📖 改进文档 -- 💻 提交代码更改 -- 🌍 添加翻译 - -**[阅读贡献指南 →](docs/developers/getting-started/development-workflow.md)** 了解如何开始。 +我们欢迎各种形式的贡献!查看[贡献指南](docs/developers/getting-started/development-workflow.md)了解如何开始。 --- @@ -176,13 +270,12 @@ - 由 [PyTauri](https://pytauri.github.io/) 驱动 - Python ↔ Rust 桥接 - UI 组件来自 [shadcn/ui](https://ui.shadcn.com/) - 图标来自 [Lucide](https://lucide.dev/) +- Agent 框架 [smolagents](https://github.com/huggingface/smolagents) --- ## 👥 Maintainers -我们负责维护这个项目。 -
IcyFeather233 @@ -194,8 +287,6 @@ ## 🙌 Contributors -我们非常感谢以下人员的积极贡献。 -
TexasOct @@ -209,7 +300,7 @@
-**[📖 文档中心](docs/README.md)** • **[👥 用户指南](docs/user-guide/README.md)** • **[💻 开发者文档](docs/developers/README.md)** • **[🤝 贡献](docs/developers/getting-started/development-workflow.md)** +**[📖 文档](docs/README.md)** • **[👥 用户指南](docs/user-guide/README.md)** • **[💻 开发者指南](docs/developers/README.md)** iDO 团队用 ❤️ 制作 diff --git a/backend/agents/action_agent.py b/backend/agents/action_agent.py index 21f7cc5..86041c2 100644 --- a/backend/agents/action_agent.py +++ b/backend/agents/action_agent.py @@ -92,6 +92,36 @@ async def extract_and_save_actions( try: logger.debug(f"ActionAgent: Processing {len(records)} records") + # Pre-persist all screenshots to prevent cache eviction during LLM processing + screenshot_records = [ + r for r in records if r.type == RecordType.SCREENSHOT_RECORD + ] + screenshot_hashes = [ + r.data.get("hash") + for r in screenshot_records + if r.data and r.data.get("hash") + ] + + if screenshot_hashes: + logger.debug( + f"ActionAgent: Pre-persisting {len(screenshot_hashes)} screenshots " + f"before LLM call to prevent cache eviction" + ) + persist_results = self.image_manager.persist_images_batch(screenshot_hashes) + + # Log pre-persistence results + success_count = sum(1 for success in persist_results.values() if success) + if success_count < len(screenshot_hashes): + logger.warning( + f"ActionAgent: Pre-persistence incomplete: " + f"{success_count}/{len(screenshot_hashes)} images persisted. " + f"Some images may already be evicted from cache." + ) + else: + logger.debug( + f"ActionAgent: Successfully pre-persisted all {len(screenshot_hashes)} screenshots" + ) + # Step 1: Extract actions using LLM actions = await self._extract_actions( records, input_usage_hint, keyboard_records, mouse_records, enable_supervisor @@ -102,10 +132,7 @@ async def extract_and_save_actions( return 0 # Step 2: Validate and resolve screenshot hashes - screenshot_records = [ - r for r in records if r.type == RecordType.SCREENSHOT_RECORD - ] - + # (screenshot_records already created above for pre-persistence) resolved_actions: List[Dict[str, Any]] = [] for action_data in actions: action_hashes = self._resolve_action_screenshot_hashes( @@ -349,6 +376,7 @@ async def extract_and_save_actions_from_scenes( keyboard_records: Optional[List[RawRecord]] = None, mouse_records: Optional[List[RawRecord]] = None, enable_supervisor: bool = False, + behavior_analysis: Optional[Dict[str, Any]] = None, ) -> int: """ Extract and save actions from pre-processed scene descriptions (memory-only, text-based) @@ -358,6 +386,7 @@ async def extract_and_save_actions_from_scenes( keyboard_records: Keyboard event records for context mouse_records: Mouse event records for context enable_supervisor: Whether to enable supervisor validation (default False) + behavior_analysis: Behavior classification result from BehaviorAnalyzer Returns: Number of actions saved @@ -370,7 +399,7 @@ async def extract_and_save_actions_from_scenes( # Step 1: Extract actions from scenes using LLM (text-only, no images) actions = await self._extract_actions_from_scenes( - scenes, keyboard_records, mouse_records, enable_supervisor + scenes, keyboard_records, mouse_records, enable_supervisor, behavior_analysis ) if not actions: @@ -455,12 +484,24 @@ def _persist_action_screenshots(self, screenshot_hashes: list[str]) -> None: results = self.image_manager.persist_images_batch(screenshot_hashes) - # Log warnings for failed persists + # Enhanced logging for failed persists failed = [h for h, success in results.items() if not success] if failed: - logger.warning( - f"Failed to persist {len(failed)} screenshots (likely evicted from memory): " - f"{[h[:8] for h in failed]}" + logger.error( + f"ActionAgent: Image persistence FAILURE: {len(failed)}/{len(screenshot_hashes)} images lost. " + f"Action will be saved with broken image references. " + f"\nFailed hashes: {[h[:8] + '...' for h in failed[:5]]}" + f"{' (and ' + str(len(failed) - 5) + ' more)' if len(failed) > 5 else ''}" + f"\nRoot cause: Images evicted from memory cache before persistence." + f"\nRecommendations:" + f"\n 1. Increase memory_ttl in config.toml (current: {self.image_manager.memory_ttl}s, recommended: ≥180s)" + f"\n 2. Run GET /image/persistence-health to check system health" + f"\n 3. Run POST /image/cleanup-broken-actions to fix existing issues" + f"\n 4. Consider increasing memory_cache_size (current: {self.image_manager.memory_cache_size}, recommended: ≥1000)" + ) + else: + logger.debug( + f"ActionAgent: Successfully persisted all {len(screenshot_hashes)} screenshots" ) except Exception as e: @@ -542,6 +583,7 @@ async def _extract_actions_from_scenes( keyboard_records: Optional[List[RawRecord]] = None, mouse_records: Optional[List[RawRecord]] = None, enable_supervisor: bool = False, + behavior_analysis: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """ Extract actions from scene descriptions using LLM (text-only, no images) @@ -551,6 +593,7 @@ async def _extract_actions_from_scenes( keyboard_records: Keyboard event records for context mouse_records: Mouse event records for context enable_supervisor: Whether to enable supervisor validation + behavior_analysis: Behavior classification result from BehaviorAnalyzer Returns: List of action dictionaries @@ -564,9 +607,19 @@ async def _extract_actions_from_scenes( # Build input usage hint from keyboard/mouse records input_usage_hint = self._build_input_usage_hint(keyboard_records, mouse_records) + # NEW: Format behavior context for prompt + behavior_context = "" + if behavior_analysis: + language = self._get_language() + from processing.behavior_analyzer import BehaviorAnalyzer + analyzer = BehaviorAnalyzer() + behavior_context = analyzer.format_behavior_context( + behavior_analysis, language + ) + # Build messages (text-only, no images) messages = self._build_action_from_scenes_messages( - scenes, input_usage_hint + scenes, input_usage_hint, behavior_context ) # Get configuration parameters @@ -820,6 +873,7 @@ def _build_action_from_scenes_messages( self, scenes: List[Dict[str, Any]], input_usage_hint: str, + behavior_context: str = "", ) -> List[Dict[str, Any]]: """ Build action extraction messages from scenes (text-only, no images) @@ -827,6 +881,7 @@ def _build_action_from_scenes_messages( Args: scenes: List of scene description dictionaries input_usage_hint: Keyboard/mouse activity hint + behavior_context: Formatted behavior classification context Returns: Message list @@ -866,6 +921,7 @@ def _build_action_from_scenes_messages( "user_prompt_template", scenes_text=scenes_text, input_usage_hint=input_usage_hint, + behavior_context=behavior_context, ) # Build complete messages (text-only, no images) diff --git a/backend/agents/event_agent.py b/backend/agents/event_agent.py index 184f590..bc321f9 100644 --- a/backend/agents/event_agent.py +++ b/backend/agents/event_agent.py @@ -31,6 +31,7 @@ class EventAgent: def __init__( self, + coordinator=None, aggregation_interval: int = 600, # 10 minutes time_window_hours: int = 1, # Look back 1 hour for unaggregated actions ): @@ -38,9 +39,11 @@ def __init__( Initialize EventAgent Args: + coordinator: Reference to PipelineCoordinator (for accessing pomodoro session) aggregation_interval: How often to run aggregation (seconds, default 10min) time_window_hours: Time window to look back for unaggregated actions (hours) """ + self.coordinator = coordinator self.aggregation_interval = aggregation_interval self.time_window_hours = time_window_hours @@ -191,6 +194,11 @@ async def _aggregate_events(self): else str(end_time) ) + # Get current pomodoro session ID if active + pomodoro_session_id = None + if self.coordinator and hasattr(self.coordinator, 'pomodoro_manager'): + pomodoro_session_id = self.coordinator.pomodoro_manager.get_current_session_id() + # Save event await self.db.events.save( event_id=event_id, @@ -199,6 +207,7 @@ async def _aggregate_events(self): start_time=start_time, end_time=end_time, source_action_ids=[str(aid) for aid in source_action_ids if aid], + pomodoro_session_id=pomodoro_session_id, ) self.stats["events_created"] += 1 diff --git a/backend/agents/knowledge_agent.py b/backend/agents/knowledge_agent.py index a2c1475..3165471 100644 --- a/backend/agents/knowledge_agent.py +++ b/backend/agents/knowledge_agent.py @@ -3,8 +3,6 @@ Extracts knowledge from screenshots and merges related knowledge periodically """ -import asyncio -import json import uuid from datetime import datetime from typing import Any, Dict, List, Optional @@ -381,4 +379,3 @@ def _calculate_knowledge_timestamp_from_scenes( ) return min(timestamps) if timestamps else datetime.now() - diff --git a/backend/agents/raw_agent.py b/backend/agents/raw_agent.py index 6f43476..c8bef28 100644 --- a/backend/agents/raw_agent.py +++ b/backend/agents/raw_agent.py @@ -75,6 +75,7 @@ async def extract_scenes( records: List[RawRecord], keyboard_records: Optional[List[RawRecord]] = None, mouse_records: Optional[List[RawRecord]] = None, + behavior_analysis: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: """ Extract scene descriptions from raw records (screenshots) @@ -83,6 +84,7 @@ async def extract_scenes( records: List of raw records (mainly screenshots) keyboard_records: Keyboard event records for timestamp extraction mouse_records: Mouse event records for timestamp extraction + behavior_analysis: Behavior classification result from BehaviorAnalyzer Returns: List of scene description dictionaries: @@ -113,9 +115,19 @@ async def extract_scenes( # Build input usage hint from keyboard/mouse records input_usage_hint = self._build_input_usage_hint(keyboard_records, mouse_records) + # NEW: Format behavior context for prompt + behavior_context = "" + if behavior_analysis: + language = self._get_language() + from processing.behavior_analyzer import BehaviorAnalyzer + analyzer = BehaviorAnalyzer() + behavior_context = analyzer.format_behavior_context( + behavior_analysis, language + ) + # Build messages (including screenshots) messages = await self._build_scene_extraction_messages( - records, input_usage_hint + records, input_usage_hint, behavior_context ) # Get configuration parameters @@ -190,6 +202,7 @@ async def _build_scene_extraction_messages( self, records: List[RawRecord], input_usage_hint: str, + behavior_context: str = "", ) -> List[Dict[str, Any]]: """ Build scene extraction messages (including system prompt, user prompt, screenshots) @@ -197,6 +210,7 @@ async def _build_scene_extraction_messages( Args: records: Record list (mainly screenshots) input_usage_hint: Keyboard/mouse activity hint + behavior_context: Formatted behavior classification context Returns: Message list @@ -209,6 +223,7 @@ async def _build_scene_extraction_messages( "raw_extraction", "user_prompt_template", input_usage_hint=input_usage_hint, + behavior_context=behavior_context, ) # Build message content (text + screenshots) diff --git a/backend/agents/session_agent.py b/backend/agents/session_agent.py index daad37c..d79a089 100644 --- a/backend/agents/session_agent.py +++ b/backend/agents/session_agent.py @@ -13,6 +13,7 @@ from core.json_parser import parse_json_from_response from core.logger import get_logger from core.settings import get_settings +from llm.focus_evaluator import get_focus_evaluator from llm.manager import get_llm_manager from llm.prompt_manager import get_prompt_manager @@ -40,6 +41,7 @@ def __init__( min_event_actions: int = 2, # Minimum 2 actions per event merge_time_gap_tolerance: int = 300, # 5 minutes tolerance for adjacent activities merge_similarity_threshold: float = 0.6, # Minimum similarity score for merging + enable_periodic_aggregation: bool = False, # Disabled by default, only use Pomodoro-triggered aggregation ): """ Initialize SessionAgent @@ -52,6 +54,7 @@ def __init__( min_event_actions: Minimum number of actions per event (default 2) merge_time_gap_tolerance: Max time gap (seconds) to consider for merging adjacent activities (default 300s/5min) merge_similarity_threshold: Minimum semantic similarity score (0-1) required for merging (default 0.6) + enable_periodic_aggregation: Whether to enable periodic aggregation (default False, only Pomodoro-triggered) """ self.aggregation_interval = aggregation_interval self.time_window_min = time_window_min @@ -60,6 +63,7 @@ def __init__( self.min_event_actions = min_event_actions self.merge_time_gap_tolerance = merge_time_gap_tolerance self.merge_similarity_threshold = merge_similarity_threshold + self.enable_periodic_aggregation = enable_periodic_aggregation # Initialize components self.db = get_db() @@ -80,7 +84,8 @@ def __init__( } logger.debug( - f"SessionAgent initialized (interval: {aggregation_interval}s, " + f"SessionAgent initialized (periodic_aggregation: {'enabled' if enable_periodic_aggregation else 'disabled'}, " + f"interval: {aggregation_interval}s, " f"time_window: {time_window_min}-{time_window_max}min, " f"quality_filter: min_duration={min_event_duration_seconds}s, min_actions={min_event_actions}, " f"merge_config: gap_tolerance={merge_time_gap_tolerance}s, similarity_threshold={merge_similarity_threshold})" @@ -98,14 +103,18 @@ async def start(self): self.is_running = True - # Start aggregation task - self.aggregation_task = asyncio.create_task( - self._periodic_session_aggregation() - ) - - logger.info( - f"SessionAgent started (aggregation interval: {self.aggregation_interval}s)" - ) + # Only start periodic aggregation if enabled + if self.enable_periodic_aggregation: + self.aggregation_task = asyncio.create_task( + self._periodic_session_aggregation() + ) + logger.info( + f"SessionAgent started with periodic aggregation (interval: {self.aggregation_interval}s)" + ) + else: + logger.info( + "SessionAgent started in Pomodoro-only mode (periodic aggregation disabled)" + ) async def stop(self): """Stop the session agent""" @@ -306,6 +315,17 @@ async def _get_unaggregated_events( filtered_count += 1 continue + # Skip Pomodoro events (handled by work phase aggregation) + # These events are processed when each Pomodoro work phase ends + if event.get("pomodoro_session_id"): + filtered_count += 1 + logger.debug( + f"Skipping Pomodoro event {event.get('id')} " + f"(session: {event.get('pomodoro_session_id')}) - " + f"handled by work phase aggregation" + ) + continue + # Quality filter 1: Check minimum number of actions source_action_ids = event.get("source_action_ids", []) if len(source_action_ids) < self.min_event_actions: @@ -487,6 +507,15 @@ async def _cluster_events_to_sessions( f"After overlap merging: {len(activities)} activities" ) + # CRITICAL: Final validation to ensure no time overlaps + is_valid, overlap_errors = self._validate_no_time_overlap(activities) + if not is_valid: + logger.error( + "Time overlap validation FAILED in event clustering:\n" + + "\n".join(overlap_errors) + + "\nThis indicates overlap detection algorithm may need adjustment" + ) + # Validate with supervisor, passing original events for semantic validation activities = await self._validate_activities_with_supervisor( activities, events @@ -502,6 +531,7 @@ async def _validate_activities_with_supervisor( self, activities: List[Dict[str, Any]], source_events: Optional[List[Dict[str, Any]]] = None, + source_actions: Optional[List[Dict[str, Any]]] = None, max_iterations: int = 3, ) -> List[Dict[str, Any]]: """ @@ -509,7 +539,8 @@ async def _validate_activities_with_supervisor( Args: activities: List of activities to validate - source_events: Optional list of all source events for semantic validation + source_events: Optional list of all source events for semantic validation (deprecated) + source_actions: Optional list of all source actions for semantic and temporal validation (preferred) max_iterations: Maximum number of validation iterations (default: 3) Returns: @@ -540,9 +571,39 @@ async def _validate_activities_with_supervisor( for activity in current_activities ] - # Build event mapping for semantic validation + # Build action/event mapping for semantic and temporal validation + # Prefer actions over events (action-based aggregation) + actions_for_validation = None events_for_validation = None - if source_events: + + if source_actions: + # Create a mapping of action IDs to actions for lookup + action_map = {action.get("id"): action for action in source_actions if action.get("id")} + + # For each activity, collect its source actions + actions_for_validation = [] + for activity in current_activities: + source_action_ids = activity.get("source_action_ids", []) + activity_actions = [] + for action_id in source_action_ids: + if action_id in action_map: + activity_actions.append(action_map[action_id]) + + # Add all actions (we'll pass them all and let supervisor map them) + actions_for_validation.extend(activity_actions) + + # Remove duplicates while preserving order + seen_ids = set() + unique_actions = [] + for action in actions_for_validation: + action_id = action.get("id") + if action_id and action_id not in seen_ids: + seen_ids.add(action_id) + unique_actions.append(action) + actions_for_validation = unique_actions + + elif source_events: + # Fallback to events for backward compatibility # Create a mapping of event IDs to events for lookup event_map = {event.get("id"): event for event in source_events if event.get("id")} @@ -568,9 +629,11 @@ async def _validate_activities_with_supervisor( unique_events.append(event) events_for_validation = unique_events - # Validate with source events + # Validate with source actions (preferred) or events (fallback) result = await supervisor.validate( - activities_for_validation, source_events=events_for_validation + activities_for_validation, + source_events=events_for_validation, + source_actions=actions_for_validation ) # Check if we have revised content @@ -710,6 +773,8 @@ def _merge_overlapping_activities( """ Detect and merge overlapping activities to prevent duplicate time consumption + Enhanced algorithm that handles all overlap cases including nested and multi-way overlaps. + Args: activities: List of activity dictionaries @@ -725,133 +790,213 @@ def _merge_overlapping_activities( key=lambda a: a.get("start_time") or datetime.min ) - merged: List[Dict[str, Any]] = [] - current = sorted_activities[0].copy() + # Iterative merging until no more overlaps found + max_iterations = 10 # Prevent infinite loop + iteration = 0 - for i in range(1, len(sorted_activities)): - next_activity = sorted_activities[i] + while iteration < max_iterations: + iteration += 1 + merged_any = False + merged: List[Dict[str, Any]] = [] + skip_indices: set = set() - # Check for time overlap or proximity - current_end = current.get("end_time") - next_start = next_activity.get("start_time") - - should_merge = False - merge_reason = "" + for i in range(len(sorted_activities)): + if i in skip_indices: + continue - if current_end and next_start: - # Convert to datetime if needed - if isinstance(current_end, str): - current_end = datetime.fromisoformat(current_end) - if isinstance(next_start, str): - next_start = datetime.fromisoformat(next_start) + current = sorted_activities[i] + current_start = self._parse_datetime(current.get("start_time")) + current_end = self._parse_datetime(current.get("end_time")) - # Calculate time gap between activities - time_gap = (next_start - current_end).total_seconds() + if not current_start or not current_end: + merged.append(current) + continue - # Case 1: Direct time overlap (original logic) - if next_start < current_end: - should_merge = True - merge_reason = "time_overlap" + # Check all remaining activities for overlap + merged_with = [] + for j in range(i + 1, len(sorted_activities)): + if j in skip_indices: + continue - # Case 2: Adjacent or small gap with semantic similarity - elif 0 <= time_gap <= self.merge_time_gap_tolerance: - # Calculate semantic similarity - similarity = self._calculate_activity_similarity(current, next_activity) + next_activity = sorted_activities[j] + next_start = self._parse_datetime(next_activity.get("start_time")) + next_end = self._parse_datetime(next_activity.get("end_time")) - if similarity >= self.merge_similarity_threshold: - should_merge = True - merge_reason = f"proximity_similarity (gap: {time_gap:.0f}s, similarity: {similarity:.2f})" + if not next_start or not next_end: + continue - # Perform merge if criteria met - if should_merge: - logger.debug( - f"Merging activities (reason: {merge_reason}): '{current.get('title')}' and '{next_activity.get('title')}'" + # Check for time overlap or proximity + should_merge, merge_reason = self._should_merge_activities( + current_start, current_end, current, + next_start, next_end, next_activity ) - # Merge source_event_ids (remove duplicates) - current_events = set(current.get("source_event_ids", [])) - next_events = set(next_activity.get("source_event_ids", [])) - merged_events = list(current_events | next_events) - - # Update end_time to the latest - next_end = next_activity.get("end_time") - if isinstance(next_end, str): - next_end = datetime.fromisoformat(next_end) - if next_end and next_end > current_end: - current["end_time"] = next_end - - # Merge topic_tags - current_tags = set(current.get("topic_tags", [])) - next_tags = set(next_activity.get("topic_tags", [])) - merged_tags = list(current_tags | next_tags) - - # Update current with merged data - current["source_event_ids"] = merged_events - current["topic_tags"] = merged_tags - - # Merge titles and descriptions based on duration - # Calculate durations to determine primary activity - current_start = current.get("start_time") - if isinstance(current_start, str): - current_start = datetime.fromisoformat(current_start) - next_start_dt = next_activity.get("start_time") - if isinstance(next_start_dt, str): - next_start_dt = datetime.fromisoformat(next_start_dt) - - current_duration = (current_end - current_start).total_seconds() if current_start and current_end else 0 - next_duration = (next_end - next_start_dt).total_seconds() if next_start_dt and next_end else 0 - - current_title = current.get("title", "") - next_title = next_activity.get("title", "") - current_desc = current.get("description", "") - next_desc = next_activity.get("description", "") - - # Select title from the longer-duration activity (primary activity) - if next_title and next_title != current_title: - if next_duration > current_duration: - # Next activity is primary, use its title - logger.debug( - f"Selected '{next_title}' as primary (duration: {next_duration:.0f}s > {current_duration:.0f}s)" - ) - current["title"] = next_title - # Add current as secondary context in description if needed - if current_desc and current_title: - current["description"] = f"{next_desc}\n\n[Related: {current_title}]\n{current_desc}" if next_desc else current_desc - elif next_desc: - current["description"] = next_desc - else: - # Current activity is primary, keep its title - logger.debug( - f"Kept '{current_title}' as primary (duration: {current_duration:.0f}s >= {next_duration:.0f}s)" - ) - # Keep current title, add next as secondary context - if next_desc and next_title: - if current_desc: - current["description"] = f"{current_desc}\n\n[Related: {next_title}]\n{next_desc}" - else: - current["description"] = next_desc - # If only next has description, use it - elif next_desc and not current_desc: - current["description"] = next_desc - else: - # Same title or one is empty, just merge descriptions - if next_desc and next_desc != current_desc: - if current_desc: - current["description"] = f"{current_desc}\n\n{next_desc}" - else: - current["description"] = next_desc + if should_merge: + logger.debug( + f"Iteration {iteration}: Merging activities (reason: {merge_reason}): " + f"'{current.get('title')}' ({current_start.strftime('%H:%M')}-{current_end.strftime('%H:%M')}) and " + f"'{next_activity.get('title')}' ({next_start.strftime('%H:%M')}-{next_end.strftime('%H:%M')})" + ) + merged_with.append(j) + skip_indices.add(j) + merged_any = True + + # Merge all overlapping activities into current + if merged_with: + for j in merged_with: + current = self._merge_two_activities( + current, sorted_activities[j], f"overlap_iter_{iteration}" + ) - logger.debug( - f"Merged into: '{current.get('title')}' with {len(merged_events)} events" - ) - continue + merged.append(current) + + # Prepare for next iteration + sorted_activities = merged + + # Exit if no merges happened in this iteration + if not merged_any: + logger.debug(f"Overlap merging converged after {iteration} iterations") + break + + if iteration >= max_iterations: + logger.warning(f"Overlap merging reached max iterations ({max_iterations})") - # No overlap, save current and move to next - merged.append(current) - current = next_activity.copy() + return sorted_activities - # Don't forget the last activity - merged.append(current) + def _parse_datetime(self, dt: Any) -> Optional[datetime]: + """Parse datetime from various formats""" + if isinstance(dt, datetime): + return dt + elif isinstance(dt, str): + try: + return datetime.fromisoformat(dt) + except (ValueError, TypeError): + return None + return None + + def _should_merge_activities( + self, + start1: datetime, end1: datetime, activity1: Dict[str, Any], + start2: datetime, end2: datetime, activity2: Dict[str, Any] + ) -> tuple[bool, str]: + """ + Determine if two activities should be merged + + Returns: + Tuple of (should_merge, merge_reason) + """ + # Case 1: Complete overlap (one contains the other) + if (start1 <= start2 and end1 >= end2) or (start2 <= start1 and end2 >= end1): + return True, "complete_overlap" + + # Case 2: Partial overlap + if (start1 <= start2 < end1) or (start2 <= start1 < end2): + return True, "partial_overlap" + + # Case 3: Adjacent or small gap with semantic similarity + # Calculate time gap (positive = gap, negative = overlap) + if start2 > end1: + time_gap = (start2 - end1).total_seconds() + else: + time_gap = (start1 - end2).total_seconds() + + if 0 <= time_gap <= self.merge_time_gap_tolerance: + similarity = self._calculate_activity_similarity(activity1, activity2) + if similarity >= self.merge_similarity_threshold: + return True, f"proximity_similarity (gap: {time_gap:.0f}s, similarity: {similarity:.2f})" + + return False, "" + + def _merge_two_activities( + self, + activity1: Dict[str, Any], + activity2: Dict[str, Any], + merge_reason: str + ) -> Dict[str, Any]: + """ + Merge two activities into one + + Args: + activity1: First activity (will be the base) + activity2: Second activity (will be merged into first) + merge_reason: Reason for merge + + Returns: + Merged activity + """ + # Parse timestamps + start1 = self._parse_datetime(activity1.get("start_time")) or datetime.min + end1 = self._parse_datetime(activity1.get("end_time")) or datetime.min + start2 = self._parse_datetime(activity2.get("start_time")) or datetime.min + end2 = self._parse_datetime(activity2.get("end_time")) or datetime.min + + # Merge time range + merged_start = min(start1, start2) + merged_end = max(end1, end2) + + # Merge source_event_ids or source_action_ids + events1 = set(activity1.get("source_event_ids", [])) + events2 = set(activity2.get("source_event_ids", [])) + actions1 = set(activity1.get("source_action_ids", [])) + actions2 = set(activity2.get("source_action_ids", [])) + + merged_events = list(events1 | events2) if events1 or events2 else None + merged_actions = list(actions1 | actions2) if actions1 or actions2 else None + + # Merge topic_tags + tags1 = set(activity1.get("topic_tags", [])) + tags2 = set(activity2.get("topic_tags", [])) + merged_tags = list(tags1 | tags2) + + # Determine primary activity based on duration + duration1 = (end1 - start1).total_seconds() + duration2 = (end2 - start2).total_seconds() + + if duration2 > duration1: + primary = activity2 + secondary = activity1 + else: + primary = activity1 + secondary = activity2 + + # Merge titles and descriptions + title = primary.get("title", "") + description = primary.get("description", "") + + # Add secondary activity context if titles differ + secondary_title = secondary.get("title", "") + secondary_desc = secondary.get("description", "") + + if secondary_title and secondary_title != title: + if secondary_desc: + description = f"{description}\n\n[Related: {secondary_title}]\n{secondary_desc}" if description else secondary_desc + elif secondary_desc and secondary_desc != description: + description = f"{description}\n\n{secondary_desc}" if description else secondary_desc + + # Calculate duration + duration_minutes = int((merged_end - merged_start).total_seconds() / 60) + + # Build merged activity + merged = { + "id": activity1.get("id", str(uuid.uuid4())), + "title": title, + "description": description, + "start_time": merged_start, + "end_time": merged_end, + "session_duration_minutes": duration_minutes, + "topic_tags": merged_tags, + } + + # Add source IDs + if merged_events: + merged["source_event_ids"] = merged_events + if merged_actions: + merged["source_action_ids"] = merged_actions + + # Preserve other fields from primary activity + for key in ["pomodoro_session_id", "pomodoro_work_phase", "focus_score", "created_at"]: + if key in primary: + merged[key] = primary[key] return merged @@ -1317,9 +1462,1035 @@ def get_stats(self) -> Dict[str, Any]: """Get statistics information""" return { "is_running": self.is_running, + "enable_periodic_aggregation": self.enable_periodic_aggregation, "aggregation_interval": self.aggregation_interval, "time_window_min": self.time_window_min, "time_window_max": self.time_window_max, "language": self._get_language(), "stats": self.stats.copy(), } + + # ========== Pomodoro Work Phase Aggregation Methods ========== + + async def aggregate_work_phase( + self, + session_id: str, + work_phase: int, + phase_start_time: datetime, + phase_end_time: datetime, + ) -> List[Dict[str, Any]]: + """ + Aggregate actions from a single Pomodoro work phase into activities + + NEW: Direct Actions → Activities aggregation (NO Events layer) + + This method is triggered when a Pomodoro work phase ends (work → break transition). + It creates activities specifically for that work phase, with intelligent merging + with activities from previous work phases in the same session. + + Args: + session_id: Pomodoro session ID + work_phase: Work phase number (1-based, e.g., 1, 2, 3, 4) + phase_start_time: When this work phase started + phase_end_time: When this work phase ended + + Returns: + List of created/updated activity dictionaries + """ + try: + logger.info( + f"Starting work phase aggregation (ACTION-BASED): session={session_id}, " + f"phase={work_phase}, duration={(phase_end_time - phase_start_time).total_seconds() / 60:.1f}min" + ) + + # Step 1: Get actions directly for this work phase (NO WAITING for events) + actions = await self._get_work_phase_actions( + session_id, phase_start_time, phase_end_time + ) + + if not actions: + logger.warning( + f"No actions found for work phase {work_phase}. " + f"User may have been idle during this phase." + ) + return [] + + logger.debug( + f"Found {len(actions)} actions for work phase {work_phase} " + f"(session: {session_id})" + ) + + # Step 2: Cluster actions into activities using NEW LLM prompt + activities = await self._cluster_actions_to_activities(actions) + + if not activities: + logger.debug( + f"No activities generated from action clustering for work phase {work_phase}" + ) + return [] + + # Step 2.3: Filter out short-duration activities + # RELAXED THRESHOLD for Pomodoro work phases (use 1 min instead of 2 min) + # Pomodoro sessions are already 25-minute focused work, so we trust shorter activities + activities = self._filter_activities_by_duration(activities, min_duration_minutes=1) + + if not activities: + logger.debug( + f"No activities remaining after duration filtering for work phase {work_phase}" + ) + return [] + + # Step 2.5: Validate activities with supervisor (check temporal continuity and semantic accuracy) + activities = await self._validate_activities_with_supervisor( + activities, source_actions=actions + ) + + # Step 2.7: CRITICAL - Final validation to ensure no time overlaps + is_valid, overlap_errors = self._validate_no_time_overlap(activities) + if not is_valid: + logger.error( + f"Time overlap validation FAILED for work phase {work_phase}:\n" + + "\n".join(overlap_errors) + + "\nForcing merge of all overlapping activities..." + ) + # Force merge overlapping activities + activities = self._merge_overlapping_activities(activities) + + # Re-validate after forced merge + is_valid, overlap_errors = self._validate_no_time_overlap(activities) + if not is_valid: + logger.error( + "Time overlap still exists after forced merge:\n" + + "\n".join(overlap_errors) + + "\nThis should not happen - keeping activities as-is but logging critical error" + ) + else: + logger.info("Successfully resolved all time overlaps after forced merge") + + # Step 3: Get existing activities from this session (previous work phases) + existing_session_activities = await self._get_session_activities(session_id) + + # Step 4: Merge with existing activities from same session (relaxed threshold) + activities_to_save, activities_to_update = await self._merge_within_session( + new_activities=activities, + existing_activities=existing_session_activities, + session_id=session_id, + ) + + # Step 5: Evaluate focus scores using LLM in parallel, then save activities + # Get focus evaluator + focus_evaluator = get_focus_evaluator() + + # Get session context (user intent and related todos) for better evaluation + session_context = await self._get_session_context(session_id) + + # Batch evaluate all activities in parallel + logger.info( + f"Starting parallel LLM focus evaluation for {len(activities_to_save)} activities" + ) + eval_tasks = [ + focus_evaluator.evaluate_activity_focus( + act, session_context=session_context + ) + for act in activities_to_save + ] + eval_results = await asyncio.gather(*eval_tasks, return_exceptions=True) + + # Process results with error handling and save activities + saved_activities = [] + for activity, eval_result in zip(activities_to_save, eval_results): + # Add pomodoro metadata + activity["pomodoro_session_id"] = session_id + activity["pomodoro_work_phase"] = work_phase + activity["aggregation_mode"] = "action_based" + + # Process LLM evaluation result with fallback + if isinstance(eval_result, Exception): + # Fallback to algorithm on error + logger.warning( + f"LLM evaluation failed for activity '{activity.get('title', 'Untitled')}': {eval_result}. " + "Falling back to algorithm-based scoring." + ) + activity["focus_score"] = self._calculate_focus_score_from_actions( + activity + ) + else: + # Use LLM evaluation score (0-100 scale) + activity["focus_score"] = eval_result.get("focus_score", 50) + logger.debug( + f"LLM focus score for '{activity.get('title', 'Untitled')[:50]}': " + f"{activity['focus_score']} (reasoning: {eval_result.get('reasoning', '')[:100]})" + ) + + # Save to database with ACTION sources + await self.db.activities.save( + activity_id=activity["id"], + title=activity["title"], + description=activity["description"], + start_time=( + activity["start_time"].isoformat() + if isinstance(activity["start_time"], datetime) + else activity["start_time"] + ), + end_time=( + activity["end_time"].isoformat() + if isinstance(activity["end_time"], datetime) + else activity["end_time"] + ), + source_event_ids=None, # NOT USED in action-based mode + source_action_ids=activity["source_action_ids"], # NEW: action IDs + aggregation_mode="action_based", # NEW FLAG + session_duration_minutes=activity.get("session_duration_minutes"), + topic_tags=activity.get("topic_tags", []), + pomodoro_session_id=activity.get("pomodoro_session_id"), + pomodoro_work_phase=activity.get("pomodoro_work_phase"), + focus_score=activity.get("focus_score"), + ) + + # NO NEED to mark events as aggregated (we're bypassing events) + + saved_activities.append(activity) + logger.debug( + f"Created activity '{activity['title']}' for work phase {work_phase} " + f"(focus_score: {activity['focus_score']:.2f}, actions: {len(activity['source_action_ids'])})" + ) + + # Step 6: Update existing activities + for update_data in activities_to_update: + await self.db.activities.save( + activity_id=update_data["id"], + title=update_data["title"], + description=update_data["description"], + start_time=( + update_data["start_time"].isoformat() + if isinstance(update_data["start_time"], datetime) + else update_data["start_time"] + ), + end_time=( + update_data["end_time"].isoformat() + if isinstance(update_data["end_time"], datetime) + else update_data["end_time"] + ), + source_event_ids=None, + source_action_ids=update_data["source_action_ids"], + aggregation_mode="action_based", + session_duration_minutes=update_data.get("session_duration_minutes"), + topic_tags=update_data.get("topic_tags", []), + pomodoro_session_id=update_data.get("pomodoro_session_id"), + pomodoro_work_phase=update_data.get("pomodoro_work_phase"), + focus_score=update_data.get("focus_score"), + ) + + # NO NEED to mark events as aggregated (action-based mode) + + saved_activities.append(update_data) + logger.debug( + f"Updated existing activity '{update_data['title']}' with new actions " + f"(merge reason: {update_data.get('_merge_reason', 'unknown')})" + ) + + logger.info( + f"Work phase {work_phase} aggregation completed (ACTION-BASED): " + f"{len(activities_to_save)} new activities, {len(activities_to_update)} updated" + ) + + return saved_activities + + except Exception as e: + logger.error( + f"Failed to aggregate work phase {work_phase} for session {session_id}: {e}", + exc_info=True, + ) + return [] + + async def _get_work_phase_events( + self, + session_id: str, + start_time: datetime, + end_time: datetime, + max_retries: int = 3, + ) -> List[Dict[str, Any]]: + """ + Get events within a specific work phase time window + + Includes retry mechanism to handle Action → Event aggregation delays. + + Args: + session_id: Pomodoro session ID + start_time: Work phase start time + end_time: Work phase end time + max_retries: Maximum number of retries (default: 3) + + Returns: + List of event dictionaries + """ + for attempt in range(max_retries): + try: + # Get all events in this time window + all_events = await self.db.events.get_in_timeframe( + start_time.isoformat(), end_time.isoformat() + ) + + # Filter for this specific pomodoro session (if events are tagged) + # Note: Events may not have pomodoro_session_id if they were created + # before the session was tagged, so we'll filter primarily by time + events = [ + event + for event in all_events + if event.get("aggregated_into_activity_id") is None + ] + + if events: + logger.debug( + f"Found {len(events)} unaggregated events for work phase " + f"(attempt {attempt + 1}/{max_retries})" + ) + return events + + # No events found, wait and retry + if attempt < max_retries - 1: + logger.debug( + f"No events found for work phase yet, retrying in 5s " + f"(attempt {attempt + 1}/{max_retries})" + ) + await asyncio.sleep(5) + + except Exception as e: + logger.error( + f"Error fetching work phase events (attempt {attempt + 1}): {e}", + exc_info=True, + ) + if attempt < max_retries - 1: + await asyncio.sleep(5) + + # All retries exhausted + logger.warning( + f"No events found for work phase after {max_retries} attempts. " + f"Time window: {start_time.isoformat()} to {end_time.isoformat()}" + ) + return [] + + async def _get_session_activities( + self, session_id: str + ) -> List[Dict[str, Any]]: + """ + Get all activities associated with a Pomodoro session + + Args: + session_id: Pomodoro session ID + + Returns: + List of activity dictionaries + """ + try: + # Query activities by pomodoro_session_id + # This will be implemented in activities repository + activities = await self.db.activities.get_by_pomodoro_session(session_id) + logger.debug( + f"Found {len(activities)} existing activities for session {session_id}" + ) + return activities + except Exception as e: + logger.error( + f"Error fetching session activities for {session_id}: {e}", + exc_info=True, + ) + return [] + + async def _get_session_context( + self, session_id: str + ) -> Dict[str, Any]: + """ + Get session context including user intent and related todos + + Args: + session_id: Pomodoro session ID + + Returns: + Dictionary containing: + - user_intent: User's description of work goal (str) + - related_todos: List of related todo items (List[Dict]) + """ + try: + # Get session information + session = await self.db.pomodoro_sessions.get_by_id(session_id) + if not session: + logger.warning(f"Session {session_id} not found") + return {"user_intent": None, "related_todos": []} + + user_intent = session.get("user_intent", "") + associated_todo_id = session.get("associated_todo_id") + + # Get related todos + related_todos = [] + if associated_todo_id: + # Fetch the specific associated todo + todo = await self.db.todos.get_by_id(associated_todo_id) + if todo and not todo.get("deleted", False): + related_todos.append(todo) + + logger.debug( + f"Session context for {session_id}: intent='{user_intent[:50] if user_intent else 'None'}', " + f"related_todos={len(related_todos)}" + ) + + return { + "user_intent": user_intent, + "related_todos": related_todos, + } + + except Exception as e: + logger.error( + f"Error fetching session context for {session_id}: {e}", + exc_info=True, + ) + return {"user_intent": None, "related_todos": []} + + def _merge_activities( + self, + existing_activity: Dict[str, Any], + new_activity: Dict[str, Any], + merge_reason: str, + ) -> Dict[str, Any]: + """ + Merge two activities into one + + Args: + existing_activity: The existing activity to merge into + new_activity: The new activity to merge + merge_reason: Reason for the merge + + Returns: + Merged activity dictionary + """ + # Parse timestamps + existing_start = ( + existing_activity["start_time"] + if isinstance(existing_activity["start_time"], datetime) + else datetime.fromisoformat(existing_activity["start_time"]) + ) + existing_end = ( + existing_activity["end_time"] + if isinstance(existing_activity["end_time"], datetime) + else datetime.fromisoformat(existing_activity["end_time"]) + ) + new_start = ( + new_activity["start_time"] + if isinstance(new_activity["start_time"], datetime) + else datetime.fromisoformat(new_activity["start_time"]) + ) + new_end = ( + new_activity["end_time"] + if isinstance(new_activity["end_time"], datetime) + else datetime.fromisoformat(new_activity["end_time"]) + ) + + # Merge source_event_ids + existing_events = set(existing_activity.get("source_event_ids", [])) + new_events = set(new_activity.get("source_event_ids", [])) + all_events = list(existing_events | new_events) + + # Update time range + merged_start = min(existing_start, new_start) + merged_end = max(existing_end, new_end) if new_end else existing_end + + # Calculate new duration + duration_minutes = int((merged_end - merged_start).total_seconds() / 60) + + # Merge topic tags + existing_tags = set(existing_activity.get("topic_tags", [])) + new_tags = set(new_activity.get("topic_tags", [])) + merged_tags = list(existing_tags | new_tags) + + # Determine primary title/description based on duration + existing_duration = (existing_end - existing_start).total_seconds() + new_duration = (new_end - new_start).total_seconds() if new_end else 0 + + if new_duration > existing_duration: + # New activity is primary + title = new_activity.get("title", existing_activity.get("title", "")) + description = new_activity.get("description", "") + if description and existing_activity.get("description"): + description = f"{description}\n\n[Related: {existing_activity.get('title')}]\n{existing_activity.get('description')}" + elif existing_activity.get("description"): + description = existing_activity.get("description") + else: + # Existing activity is primary + title = existing_activity.get("title", "") + description = existing_activity.get("description", "") + if new_activity.get("description") and new_activity.get("title"): + if description: + description = f"{description}\n\n[Related: {new_activity.get('title')}]\n{new_activity.get('description')}" + else: + description = new_activity.get("description", "") + + # Create merged activity + merged_activity = { + "id": existing_activity["id"], + "title": title, + "description": description, + "start_time": merged_start, + "end_time": merged_end, + "source_event_ids": all_events, + "session_duration_minutes": duration_minutes, + "topic_tags": merged_tags, + } + + return merged_activity + + async def _merge_within_session( + self, + new_activities: List[Dict[str, Any]], + existing_activities: List[Dict[str, Any]], + session_id: str, + ) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """ + Merge new activities with existing activities from the same Pomodoro session + + Uses relaxed similarity threshold compared to global merging, since activities + within the same Pomodoro session are more likely to be related (same user intent). + + Merge conditions: + - Must have same pomodoro_session_id + - Case 1: Direct time overlap + - Case 2: Time gap ≤ 5 minutes AND semantic similarity ≥ 0.5 (relaxed from 0.6) + + Args: + new_activities: Newly generated activities from current work phase + existing_activities: Activities from previous work phases in same session + session_id: Pomodoro session ID + + Returns: + Tuple of (activities_to_save, activities_to_update) + - activities_to_save: New activities that don't merge with existing ones + - activities_to_update: Existing activities that absorbed new activities + """ + if not existing_activities: + # No existing activities to merge with + return (new_activities, []) + + # Relaxed similarity threshold for same-session merging + session_similarity_threshold = 0.5 # Lower than global threshold (0.6) + + activities_to_save = [] + activities_to_update = [] + + for new_activity in new_activities: + merged = False + + # Check for merge with each existing activity + for existing_activity in existing_activities: + # Parse timestamps + new_start = ( + new_activity["start_time"] + if isinstance(new_activity["start_time"], datetime) + else datetime.fromisoformat(new_activity["start_time"]) + ) + new_end = ( + new_activity["end_time"] + if isinstance(new_activity["end_time"], datetime) + else datetime.fromisoformat(new_activity["end_time"]) + ) + existing_start = ( + existing_activity["start_time"] + if isinstance(existing_activity["start_time"], datetime) + else datetime.fromisoformat(existing_activity["start_time"]) + ) + existing_end = ( + existing_activity["end_time"] + if isinstance(existing_activity["end_time"], datetime) + else datetime.fromisoformat(existing_activity["end_time"]) + ) + + # Check merge conditions + should_merge = False + merge_reason = "" + + # Case 1: Time overlap + if new_start <= existing_end and new_end >= existing_start: + should_merge = True + merge_reason = "time_overlap" + + # Case 2: Adjacent/close with semantic similarity + else: + # Calculate time gap + if new_start > existing_end: + time_gap = (new_start - existing_end).total_seconds() + else: + time_gap = (existing_start - new_end).total_seconds() + + if 0 <= time_gap <= self.merge_time_gap_tolerance: + # Calculate semantic similarity (reuse existing method) + similarity = self._calculate_activity_similarity( + existing_activity, new_activity + ) + + if similarity >= session_similarity_threshold: + should_merge = True + merge_reason = f"session_proximity_similarity (gap: {time_gap:.0f}s, similarity: {similarity:.2f})" + + if should_merge: + # Merge new activity into existing activity + merged_activity = self._merge_activities( + existing_activity, new_activity, merge_reason + ) + + # Track which new events were added + merged_activity["_new_event_ids"] = new_activity["source_event_ids"] + merged_activity["_merge_reason"] = merge_reason + + activities_to_update.append(merged_activity) + merged = True + + logger.debug( + f"Merging new activity '{new_activity['title']}' into " + f"existing '{existing_activity['title']}' (reason: {merge_reason})" + ) + break + + if not merged: + # No merge found, save as new activity + activities_to_save.append(new_activity) + + return (activities_to_save, activities_to_update) + + def _calculate_focus_score(self, activity: Dict[str, Any]) -> float: + """ + Calculate focus score for an activity based on multiple factors + + Focus score ranges from 0.0 (very unfocused) to 1.0 (highly focused). + + Factors: + 1. Event density (30% weight): Events per minute + - High density (>2 events/min) → frequent task switching → lower score + - Low density (<0.5 events/min) → sustained work or idle → moderate/high score + + 2. Topic consistency (40% weight): Number of unique topics + - 1 topic → highly focused on single subject → high score + - 2 topics → related tasks → good score + - 3+ topics → scattered attention → lower score + + 3. Duration (30% weight): Time spent on activity + - >20 min → deep work session → high score + - 10-20 min → moderate work session → good score + - 5-10 min → brief focus → moderate score + - <5 min → very brief → low score + + Args: + activity: Activity dictionary with source_event_ids, session_duration_minutes, topic_tags + + Returns: + Focus score between 0.0 and 1.0 + """ + score = 1.0 + + # Factor 1: Event density (30% weight) + event_count = len(activity.get("source_event_ids", [])) + duration_minutes = activity.get("session_duration_minutes", 1) + + if duration_minutes > 0: + events_per_minute = event_count / duration_minutes + + if events_per_minute > 2.0: + # Too many events per minute → frequent switching + score *= 0.7 + elif events_per_minute < 0.5: + # Very few events → either deep focus or idle time + # Slightly penalize to account for possible idle time + score *= 0.95 + # else: 0.5-2.0 events/min is normal working pace, no adjustment + + # Factor 2: Topic consistency (40% weight) + topic_count = len(activity.get("topic_tags", [])) + + if topic_count == 0: + # No topics identified → unclear focus + score *= 0.8 + elif topic_count == 1: + # Single topic → highly focused + score *= 1.0 + elif topic_count == 2: + # Two related topics → good focus + score *= 0.9 + else: + # Multiple topics → scattered attention + score *= 0.7 + + # Factor 3: Duration (30% weight) + if duration_minutes > 20: + # Deep work session + score *= 1.0 + elif duration_minutes > 10: + # Moderate work session + score *= 0.8 + elif duration_minutes > 5: + # Brief focus period + score *= 0.6 + else: + # Very brief activity + score *= 0.4 + + # Ensure score stays within bounds + final_score = min(1.0, max(0.0, score)) + + return round(final_score, 2) + + async def _get_work_phase_actions( + self, + session_id: str, + start_time: datetime, + end_time: datetime, + ) -> List[Dict[str, Any]]: + """ + Get actions within a specific work phase time window + + Args: + session_id: Pomodoro session ID (not used for filtering, as actions don't have session_id) + start_time: Work phase start time + end_time: Work phase end time + + Returns: + List of action dictionaries + """ + try: + # Get all actions in this time window + actions = await self.db.actions.get_in_timeframe( + start_time.isoformat(), end_time.isoformat() + ) + + logger.debug( + f"Found {len(actions)} actions for work phase " + f"({start_time.isoformat()} to {end_time.isoformat()})" + ) + + return actions + + except Exception as e: + logger.error( + f"Error fetching work phase actions: {e}", + exc_info=True, + ) + return [] + + async def _cluster_actions_to_activities( + self, actions: List[Dict[str, Any]] + ) -> List[Dict[str, Any]]: + """ + Use LLM to cluster actions into activity-level work sessions + + Uses the new 'action_aggregation' prompt (not 'session_aggregation') + + Args: + actions: List of action dictionaries + + Returns: + List of activity dictionaries with source_action_ids + """ + if not actions: + return [] + + try: + logger.debug(f"Clustering {len(actions)} actions into activities (ACTION-BASED)") + + # Build actions JSON with index + actions_with_index = [ + { + "index": i + 1, + "title": action.get("title", ""), + "description": action.get("description", ""), + "timestamp": action.get("timestamp", ""), + } + for i, action in enumerate(actions) + ] + actions_json = json.dumps(actions_with_index, ensure_ascii=False, indent=2) + + # Get current language and prompt manager + language = self._get_language() + from llm.prompt_manager import get_prompt_manager + + prompt_manager = get_prompt_manager(language) + + # Build messages using NEW prompt + messages = prompt_manager.build_messages( + "action_aggregation", # NEW PROMPT CATEGORY + "user_prompt_template", + actions_json=actions_json, + ) + + # Get configuration parameters + config_params = prompt_manager.get_config_params("action_aggregation") + + # Call LLM + response = await self.llm_manager.chat_completion(messages, **config_params) + content = response.get("content", "").strip() + + # Parse JSON (already imported at top of file) + result = parse_json_from_response(content) + + if not isinstance(result, dict): + logger.warning( + f"Action clustering result format error: {content[:200]}" + ) + return [] + + activities_data = result.get("activities", []) + + # Convert to complete activity objects + activities = [] + for activity_data in activities_data: + # Normalize source indexes + normalized_indexes = self._normalize_source_indexes( + activity_data.get("source"), len(actions) + ) + + if not normalized_indexes: + continue + + source_action_ids: List[str] = [] + source_actions: List[Dict[str, Any]] = [] + for idx in normalized_indexes: + action = actions[idx - 1] + action_id = action.get("id") + if action_id: + source_action_ids.append(action_id) + source_actions.append(action) + + if not source_actions: + continue + + # Get timestamps from ACTIONS (not events) + start_time = None + end_time = None + for a in source_actions: + timestamp = a.get("timestamp") + if timestamp: + if isinstance(timestamp, str): + timestamp = datetime.fromisoformat(timestamp) + if start_time is None or timestamp < start_time: + start_time = timestamp + if end_time is None or timestamp > end_time: + end_time = timestamp + + if not start_time: + start_time = datetime.now() + if not end_time: + end_time = start_time + + # Calculate duration + duration_seconds = (end_time - start_time).total_seconds() + + # CRITICAL FIX: For single-action activities, use minimum duration + # This prevents activities from being filtered out due to zero duration + if len(source_actions) == 1 and duration_seconds < 60: + # Single action represents a meaningful work moment + # Use 5 minutes as reasonable default duration + duration_minutes = 5 + logger.debug( + f"Single-action activity: using default duration of 5min " + f"(original: {duration_seconds:.1f}s)" + ) + else: + duration_minutes = int(duration_seconds / 60) + + # Extract topic tags from LLM response + topic_tags = activity_data.get("topic_tags", []) + + activity = { + "id": str(uuid.uuid4()), + "title": activity_data.get("title", "Unnamed activity"), + "description": activity_data.get("description", ""), + "start_time": start_time, + "end_time": end_time, + "source_action_ids": source_action_ids, # NEW: action IDs instead of event IDs + "topic_tags": topic_tags, + "session_duration_minutes": duration_minutes, + "created_at": datetime.now(), + } + + activities.append(activity) + + logger.debug( + f"Clustering completed: generated {len(activities)} activities from {len(actions)} actions" + ) + + return activities + + except Exception as e: + logger.error( + f"Failed to cluster actions to activities: {e}", exc_info=True + ) + return [] + + def _filter_activities_by_duration( + self, activities: List[Dict[str, Any]], min_duration_minutes: int = 2 + ) -> List[Dict[str, Any]]: + """ + Filter out activities with duration less than min_duration_minutes + + Args: + activities: List of activities to filter + min_duration_minutes: Minimum duration in minutes (default: 2) + + Returns: + Filtered list of activities + """ + if not activities: + return activities + + filtered_activities = [] + filtered_count = 0 + + for activity in activities: + # Calculate duration + start_time = activity.get("start_time") + end_time = activity.get("end_time") + + if not start_time or not end_time: + # No time info, keep it + filtered_activities.append(activity) + continue + + # Convert to datetime if needed + if isinstance(start_time, str): + start_time = datetime.fromisoformat(start_time) + if isinstance(end_time, str): + end_time = datetime.fromisoformat(end_time) + + # Calculate duration in minutes + duration_minutes = (end_time - start_time).total_seconds() / 60 + + if duration_minutes >= min_duration_minutes: + filtered_activities.append(activity) + else: + filtered_count += 1 + logger.debug( + f"Filtered out short activity '{activity.get('title', 'Unnamed')}' " + f"(duration: {duration_minutes:.1f}min < {min_duration_minutes}min)" + ) + + if filtered_count > 0: + logger.info( + f"Filtered out {filtered_count} activities with duration < {min_duration_minutes} minutes" + ) + + return filtered_activities + + def _validate_no_time_overlap( + self, activities: List[Dict[str, Any]] + ) -> tuple[bool, List[str]]: + """ + Final validation to ensure no time overlaps exist in activities + + Args: + activities: List of activities to validate + + Returns: + Tuple of (is_valid, list of error messages) + """ + if len(activities) <= 1: + return True, [] + + errors = [] + + # Check all pairs for overlap + for i in range(len(activities)): + activity1 = activities[i] + start1 = self._parse_datetime(activity1.get("start_time")) + end1 = self._parse_datetime(activity1.get("end_time")) + + if not start1 or not end1: + continue + + for j in range(i + 1, len(activities)): + activity2 = activities[j] + start2 = self._parse_datetime(activity2.get("start_time")) + end2 = self._parse_datetime(activity2.get("end_time")) + + if not start2 or not end2: + continue + + # Check for any time overlap + if (start1 <= start2 < end1) or (start2 <= start1 < end2) or \ + (start1 <= start2 and end1 >= end2) or (start2 <= start1 and end2 >= end1): + error_msg = ( + f"Time overlap detected: " + f"Activity '{activity1.get('title', 'Untitled')}' " + f"({start1.strftime('%H:%M')}-{end1.strftime('%H:%M')}) " + f"overlaps with " + f"Activity '{activity2.get('title', 'Untitled')}' " + f"({start2.strftime('%H:%M')}-{end2.strftime('%H:%M')})" + ) + errors.append(error_msg) + + return len(errors) == 0, errors + + def _calculate_focus_score_from_actions(self, activity: Dict[str, Any]) -> float: + """ + Calculate focus score for an ACTION-BASED activity + + Similar to _calculate_focus_score() but uses actions instead of events + + Focus score factors: + 1. Action density (30% weight): Actions per minute + 2. Topic consistency (40% weight): Number of unique topics + 3. Duration (30% weight): Time spent on activity + + Args: + activity: Activity dictionary with source_action_ids + + Returns: + Focus score between 0.0 and 1.0 + """ + score = 1.0 + + # Factor 1: Action density (30% weight) + action_count = len(activity.get("source_action_ids", [])) + duration_minutes = activity.get("session_duration_minutes", 1) + + if duration_minutes > 0: + actions_per_minute = action_count / duration_minutes + + # Actions are finer-grained than events, so adjust thresholds + # Normal range: 0.5-3 actions/min (vs 0.5-2 events/min) + if actions_per_minute > 3.0: + # Too many actions per minute → frequent switching + score *= 0.7 + elif actions_per_minute < 0.5: + # Very few actions → either deep focus or idle time + score *= 0.95 + # else: 0.5-3.0 actions/min is normal working pace, no adjustment + + # Factor 2: Topic consistency (40% weight) + topic_count = len(activity.get("topic_tags", [])) + + if topic_count == 0: + # No topics identified → unclear focus + score *= 0.8 + elif topic_count == 1: + # Single topic → highly focused + score *= 1.0 + elif topic_count == 2: + # Two related topics → good focus + score *= 0.9 + else: + # Multiple topics → scattered attention + score *= 0.7 + + # Factor 3: Duration (30% weight) + if duration_minutes > 20: + # Deep work session + score *= 1.0 + elif duration_minutes > 10: + # Moderate work session + score *= 0.8 + elif duration_minutes > 5: + # Brief focus period + score *= 0.6 + else: + # Very brief activity + score *= 0.4 + + # Ensure score stays within bounds + final_score = min(1.0, max(0.0, score)) + + return round(final_score, 2) diff --git a/backend/agents/supervisor.py b/backend/agents/supervisor.py index 91b8617..e1900b9 100644 --- a/backend/agents/supervisor.py +++ b/backend/agents/supervisor.py @@ -421,12 +421,14 @@ async def validate( Args: content: List of activity items to validate **kwargs: Additional context - - source_events: Optional list of source events for semantic validation + - source_events: Optional list of source events for semantic validation (deprecated) + - source_actions: Optional list of source actions for semantic and temporal validation (preferred) Returns: SupervisorResult with validation results """ source_events = kwargs.get("source_events") + source_actions = kwargs.get("source_actions") if not content: return SupervisorResult( @@ -439,9 +441,48 @@ async def validate( activities_json = json.dumps(content, ensure_ascii=False, indent=2, default=str) - # Build source events section if provided + # Build source section (prefer actions over events) source_events_section = "" - if source_events: + if source_actions: + # Enrich actions with duration for better analysis + enriched_actions = [] + for action in source_actions: + action_copy = action.copy() + start = action.get("start_time") or action.get("timestamp") + end = action.get("end_time") + + if start and end: + # Calculate duration + if isinstance(start, str): + start = datetime.fromisoformat(start) + if isinstance(end, str): + end = datetime.fromisoformat(end) + + duration = (end - start).total_seconds() + action_copy["duration_seconds"] = int(duration) + action_copy["duration_display"] = self._format_duration(duration) + elif start: + # Action with only timestamp (no end time) + action_copy["duration_seconds"] = 0 + action_copy["duration_display"] = "instant" + + enriched_actions.append(action_copy) + + source_actions_json = json.dumps( + enriched_actions, ensure_ascii=False, indent=2, default=str + ) + source_events_section = f""" +【Source Actions for Semantic and Temporal Validation】 +The following are the source actions that were aggregated into the activities above. +Each action includes its duration and timestamp. Use these to: +1. Calculate time distribution across different themes +2. Identify the dominant theme (most time spent) +3. Verify that activity titles reflect the dominant theme, not minor topics +4. **Check temporal continuity**: Calculate time gaps between actions and ensure adjacent activities have reasonable time intervals + +{source_actions_json} +""" + elif source_events: # Enrich events with duration for better analysis enriched_events = [] for event in source_events: diff --git a/backend/agents/todo_agent.py b/backend/agents/todo_agent.py index deb434b..1639525 100644 --- a/backend/agents/todo_agent.py +++ b/backend/agents/todo_agent.py @@ -152,6 +152,7 @@ async def extract_todos_from_scenes( # Calculate timestamp from scenes todo_timestamp = self._calculate_todo_timestamp_from_scenes(scenes) + # AI-generated todos will have automatic expiration set in save() await self.db.todos.save( todo_id=todo_id, title=todo_data.get("title", ""), @@ -159,6 +160,7 @@ async def extract_todos_from_scenes( keywords=todo_data.get("keywords", []), created_at=todo_timestamp.isoformat(), completed=todo_data.get("completed", False), + source_type="ai", ) saved_count += 1 diff --git a/backend/config/config.toml b/backend/config/config.toml index 2f33b91..0e89bc9 100644 --- a/backend/config/config.toml +++ b/backend/config/config.toml @@ -28,9 +28,9 @@ enable_phash = true # Memory-first storage configuration enable_memory_first = true # Master switch -memory_ttl_multiplier = 2.5 # TTL = processing_interval * multiplier +memory_ttl_multiplier = 5.0 # TTL = processing_interval * multiplier (increased for better persistence) memory_ttl_min = 60 # Minimum TTL (seconds) -memory_ttl_max = 120 # Maximum TTL (seconds) +memory_ttl_max = 300 # Maximum TTL (seconds) (increased from 120 to prevent eviction during LLM processing) # Screenshot configuration [screenshot] @@ -141,7 +141,8 @@ crop_threshold = 30 # Memory cache size (images) # Description: Cache recent image base64 data in memory # Recommendation: 200-500 (memory usage ~100-250MB) -memory_cache_size = 500 +# Increased to 1000 for better persistence reliability (memory usage ~500-1000MB) +memory_cache_size = 1000 # ========== Optimization Effect Estimation ========== # Based on default configuration (aggressive + hybrid), with 20 original screenshots as example: @@ -187,9 +188,11 @@ merge_similarity_threshold = 0.6 # Similarity threshold (0.0-1.0) # Description: Two screenshots are considered duplicates if similarity exceeds this value # - 0.85-0.90: Relaxed mode (retains more screenshots, suitable for fast-changing scenarios) -# - 0.90-0.95: Standard mode (recommended, balances deduplication and information retention) ⭐ +# - 0.90-0.95: Standard mode (recommended, balances deduplication and information retention) # - 0.95-0.98: Strict mode (aggressive deduplication, suitable for static content scenarios) -screenshot_similarity_threshold = 0.92 # Optimized: increased from 0.90 for more aggressive deduplication +# OPTIMIZED: Lowered from 0.92 to 0.88 for video-watching scenarios ⭐ +# This allows more content variations to be captured (e.g., video progress, UI changes) +screenshot_similarity_threshold = 0.88 # Hash cache size # Description: Keep hash values of the last N screenshots for comparison @@ -210,11 +213,43 @@ screenshot_hash_algorithms = ["phash", "dhash", "average_hash"] # - false: Disable - always use fixed threshold enable_adaptive_threshold = true +# ========== Static Scene Optimization ========== +# Time-based forced processing (seconds) +# Description: Maximum time to wait before forcing action extraction, even if screenshot threshold not reached +# This ensures activity is captured in static scenes (reading, watching videos, thinking) +# - 120: Aggressive (2 minutes, more LLM calls but better coverage) +# - 180: Balanced (3 minutes, recommended) ⭐ +# - 300: Conservative (5 minutes, fewer LLM calls) +max_accumulation_time = 180 + +# Periodic sampling interval (seconds) +# Description: Minimum interval between kept samples during deduplication +# Even if screenshots are identical (static scene), at least one sample is kept every N seconds +# This ensures time coverage in the accumulated screenshots +# - 20: Aggressive (more samples, higher LLM cost) +# - 30: Balanced (recommended) ⭐ +# - 45: Conservative (fewer samples) +min_sample_interval = 30 + # Action extraction configuration (RawAgent → ActionAgent flow) -# Threshold calculation: target 8 screenshots after filtering -# Typical filtering rate: 30-40% (dedup + content analysis) -# Formula: threshold = max_screenshots / (1 - filter_rate) ≈ 8 / 0.65 ≈ 12 -action_extraction_threshold = 12 # Optimized: trigger when 12 screenshots accumulated (~12 seconds) +# OPTIMIZED: Balanced threshold for better data generation in Pomodoro sessions +# +# Threshold calculation: Balance between LLM calls and data completeness +# With 1 screenshot/second capture rate: +# - 12: Trigger every ~12 seconds (frequent, 300 calls/hour, high data completeness) +# - 20: Trigger every ~20 seconds (balanced, 180 calls/hour) +# - 25: Trigger every ~25 seconds (optimized, 144 calls/hour) ⭐ OPTIMIZED +# - 40: Trigger every ~40 seconds (conservative, 90 calls/hour, may miss activities) +# +# RATIONALE: Previous threshold of 40 caused "no actions found" errors in Pomodoro sessions +# because screenshots were too sparse (especially for video-watching scenarios). +# Lowering to 25 provides: +# - 60% more action generation frequency (40→25 screenshots) +# - Better activity coverage in diverse scenarios (coding, watching, reading) +# - Acceptable token cost (~$20-25/hour vs $18.50/hour at threshold=40) +# +# Note: Combined with screenshot_similarity_threshold=0.88, this provides optimal balance +action_extraction_threshold = 25 # OPTIMIZED: Lowered from 40 for better Pomodoro data generation max_screenshots_per_extraction = 8 # Final limit: ImageSampler will select best 8 from filtered results # UI configuration @@ -226,6 +261,68 @@ recent_events_count = 5 # Number of recent events to display (can be modified i # System language configuration, affects prompt selection default_language = "zh" # zh | en +# Pomodoro mode screenshot buffering configuration +[pomodoro] +# Enable screenshot buffering during Pomodoro sessions +# Description: Batch RawRecord generation to reduce memory pressure +# - true: Enable buffering (recommended for Pomodoro mode) ⭐ +# - false: Disable (use normal flow) +enable_screenshot_buffering = true + +# Count threshold - trigger batch when this many screenshots accumulated +# Description: With 1 screenshot/sec capture rate: +# - 25: Batch every ~25 seconds (matches action_extraction_threshold) ⭐ OPTIMIZED +# - 40: Batch every ~40 seconds (balanced) +# - 60: Batch every ~60 seconds (conservative) +# - 100: Batch every ~100 seconds (less frequent, more memory) +# OPTIMIZED: Lowered from 40 to 25 to align with action_extraction_threshold +# This ensures faster action generation and better Pomodoro session data +screenshot_buffer_count_threshold = 25 + +# Time threshold - trigger batch after this many seconds elapsed +# Description: Ensures timely processing even if screenshot rate is low +# - 30: More frequent batching (recommended) ⭐ OPTIMIZED +# - 45: Balanced +# - 60: Conservative +# OPTIMIZED: Lowered from 45s to 30s for more responsive action extraction +screenshot_buffer_time_threshold = 30 + +# Maximum buffer size - emergency flush to prevent memory overflow +# Description: Should be 2-4x count_threshold for safety +# - 50: Conservative (2x count_threshold=25) +# - 100: Standard (4x count_threshold=25) ⭐ OPTIMIZED +# - 160: Generous (6.4x count_threshold=25) +screenshot_buffer_max_size = 160 + +# Processing timeout - maximum time to wait for batch processing +# Description: Timeout for LLM calls (should be 2x max LLM timeout) +# - 600: 10 minutes (for fast LLM providers) +# - 720: 12 minutes (recommended for most cases) ⭐ +# - 900: 15 minutes (for slow/local LLM providers) +screenshot_buffer_processing_timeout = 720 + +# ========== Coding Scene Optimization ========== +# Coding-specific optimizations for IDEs, terminals, and code editors +# When a coding app is detected, more permissive thresholds are used +# to capture small but meaningful changes (cursor movement, typing) +[coding_optimization] +# Enable coding scene detection and adaptive thresholds +enabled = true + +# Similarity threshold for coding scenes (0.0-1.0) +# Higher value = more screenshots retained (less aggressive deduplication) +# - 0.90: Conservative (may still miss some small changes) +# - 0.92: Balanced (recommended) ⭐ +# - 0.95: Aggressive (retains most changes, higher LLM cost) +coding_similarity_threshold = 0.92 + +# Content analysis thresholds for coding (dark themes) +# These are lower than default because: +# - Dark-themed IDEs have low contrast +# - Typing creates small visual changes +coding_min_contrast = 25.0 +coding_min_activity = 5.0 + # Database configuration [database] # Database file path (relative to data directory or absolute path) diff --git a/backend/config/loader.py b/backend/config/loader.py index ec188c6..d7630e9 100644 --- a/backend/config/loader.py +++ b/backend/config/loader.py @@ -134,7 +134,20 @@ def _merge_configs( result = base.copy() # Filter out system-level sections from user config - system_sections = {'processing', 'monitoring', 'image', 'image_optimization'} + # These settings are managed by backend and should not be overridden by users + system_sections = { + 'monitoring', # Capture intervals, processing intervals + 'server', # Host, port, debug mode + 'logging', # Log level, directory, file rotation + 'image', # Image compression, dimensions, phash + 'image_optimization', # Optimization strategies, thresholds + 'processing', # Screenshot deduplication, similarity thresholds + 'ui', # UI settings (managed by frontend/backend) + 'pomodoro', # Pomodoro buffer settings (system-level) + } + + # System-level keys within [screenshot] section + screenshot_system_keys = {'smart_capture_enabled', 'inactive_timeout'} for key, value in override.items(): # Skip system-level sections @@ -145,6 +158,25 @@ def _merge_configs( ) continue + # Special handling for [screenshot] section (mixed user/system settings) + if key == 'screenshot' and isinstance(value, dict): + # Filter out system-level keys + user_screenshot_config = { + k: v for k, v in value.items() + if k not in screenshot_system_keys + } + if screenshot_system_keys & set(value.keys()): + logger.debug( + f"Ignoring system-level keys in [screenshot]: " + f"{screenshot_system_keys & set(value.keys())}" + ) + # Merge user-level screenshot settings + if key in result and isinstance(result[key], dict): + result[key] = self._merge_configs(result[key], user_screenshot_config) + else: + result[key] = user_screenshot_config + continue + if key in result and isinstance(result[key], dict) and isinstance(value, dict): # Recursively merge nested dictionaries result[key] = self._merge_configs(result[key], value) @@ -177,7 +209,12 @@ def _get_default_config_content(self) -> str: """Get default configuration content for user configuration Note: Only user-configurable items should be in user config. - Development settings (logging, monitoring, etc.) are in project config. + System settings (logging, monitoring, processing, etc.) are in backend/config/config.toml. + + User-configurable settings: + - [database]: Database storage path + - [screenshot]: Screenshot storage path, screen settings + - [language]: UI language preference """ # Avoid circular imports: use path directly, don't import get_data_dir config_dir = Path.home() / ".config" / "ido" @@ -187,9 +224,17 @@ def _get_default_config_content(self) -> str: return f"""# iDO User Configuration File # Location: ~/.config/ido/config.toml # -# This file contains user-level settings only. -# System-level settings ([processing], [monitoring], [image], etc.) are managed -# in project configuration (backend/config/config.toml) and cannot be overridden here. +# ⚠️ IMPORTANT: This file contains USER-LEVEL settings only. +# +# System-level settings (capture intervals, processing thresholds, optimization parameters, etc.) +# are managed in backend/config/config.toml and CANNOT be overridden here. +# +# If you add system-level sections here, they will be IGNORED during config merge. +# +# User-configurable settings: +# - [database]: Database file location +# - [screenshot]: Screenshot storage path, monitor settings +# - [language]: UI language preference [database] # Database storage location @@ -198,9 +243,23 @@ def _get_default_config_content(self) -> str: [screenshot] # Screenshot storage location save_path = '{screenshots_dir}' -# Force save interval when screenshots are being filtered as duplicates (seconds) -# Even if screenshots are identical, force save one after this interval + +# Force save interval (seconds) +# When screenshots are filtered as duplicates, force save one after this interval force_save_interval = 60 + +# Monitor/screen configuration (auto-detected, can be customized) +# Note: This will be auto-populated when application first runs +# [[screenshot.screen_settings]] +# monitor_index = 1 +# monitor_name = "Display 1" +# is_enabled = true +# resolution = "1920x1080" +# is_primary = true + +[language] +# UI language: "en" (English) or "zh" (Chinese) +default_language = "zh" """ def _replace_env_vars(self, content: str) -> str: diff --git a/backend/config/prompts_en.toml b/backend/config/prompts_en.toml index 252d140..8922385 100644 --- a/backend/config/prompts_en.toml +++ b/backend/config/prompts_en.toml @@ -85,6 +85,30 @@ Before and after generation, automatically check: - If multiple similar operations are split into multiple actions → merge into one action; - If `keywords` contain generic terms (e.g., "code", "browser", "document") → replace them. +------------------------------------- +【Behavior Context Interpretation】 +------------------------------------- +The system provides behavior classification based on keyboard and mouse patterns: + +**OPERATION Mode (Active Work):** +- High keyboard activity (frequent typing, shortcuts) +- Precise mouse clicks and drags +- User is actively creating, coding, writing, or designing +- Actions should focus on: what was built/written/created, technical details, problem-solving + +**BROWSING Mode (Passive Consumption):** +- Low keyboard activity (minimal typing) +- Continuous scrolling, few clicks +- User is consuming content (reading, watching, learning) +- Actions should focus on: what was learned/researched, key topics, sources + +**MIXED Mode:** +- Combination of both patterns +- User may be alternating between creation and reference +- Actions should capture both aspects + +**Important:** Use behavior context as a hint, NOT a strict constraint. Visual evidence from screenshots takes priority. + ------------------------------------- 【Output Objective】 ------------------------------------- @@ -99,6 +123,9 @@ user_prompt_template = """Here are the user's recent screenshots: Here is the user's mouse/keyboard usage during this period: {input_usage_hint} +**Behavior Classification:** +{behavior_context} + **Important Note About Perception State:** - If keyboard/mouse perception is disabled, the system cannot capture these inputs, so you will not have that contextual information. - When a certain input type's perception is disabled, rely more on visual clues from screenshots to infer user activities. @@ -803,6 +830,141 @@ Think carefully, then output **only** the following JSON (no explanatory text): }} ```""" +[prompts.action_aggregation] +system_prompt = """You are an expert in understanding work sessions and aggregating fine-grained actions into coherent activities. + +Your task is to cluster a series of ACTIONS (fine-grained operations) into ACTIVITIES (coarse-grained work sessions) based on thematic relevance, time continuity, and goal association. + +------------------------------------- +【Core Principles】 +------------------------------------- +1. **Time Uniqueness (HIGHEST PRIORITY)**: Only one activity can exist in the same time period + - If multiple actions overlap or are close in time, they MUST be merged into one activity + - When user does multiple things in the same period, identify the primary activity and mention secondary activities in description + - Absolutely PROHIBIT creating overlapping activities +2. **Thematic Coherence**: Group actions that serve the same high-level goal or project +3. **Time Continuity**: Actions within reasonable time gaps (≤5min) likely belong together +4. **Goal Association**: Different objects/files serving the same work goal should merge +5. **Project Consistency**: Same repo/branch/feature indicates same activity +6. **Workflow Continuity**: Actions forming a logical workflow (write → test → debug → fix) + +------------------------------------- +【Activity Granularity Guidelines】 +------------------------------------- +- One activity = one focused work session on a coherent theme +- Merge actions on the same file/feature/problem into one activity +- Separate activities when switching to a different project/goal/context +- Examples of ONE activity: + - "Implement user authentication feature" (includes writing code, testing, debugging, fixing) + - "Research and implement Docker deployment" (includes reading docs, writing config, troubleshooting) + - "Debug payment gateway integration" (includes analyzing logs, testing API, fixing bugs) +- Examples of MULTIPLE activities: + - "Implement auth feature" + "Review email from manager" + "Update project roadmap" + +------------------------------------- +【Activity Structure】 +------------------------------------- +- **title**: Concise summary of the work session (what was accomplished) + Format: `[Action Verb] [Object/Feature] ([Context or Purpose])` + Examples: + - "Implement user authentication with JWT tokens" + - "Debug and fix Docker build configuration errors" + - "Research TypeScript generics for API client refactoring" + +- **description**: Comprehensive narrative of the work session: + - Context: What project/feature/problem was being worked on + - Actions taken: High-level summary of key steps (not exhaustive list) + - Challenges: Any issues encountered and how they were resolved + - Outcome: What was achieved by the end + +- **topic_tags**: 2-5 high-level semantic tags + Examples: ["authentication", "backend"], ["docker", "deployment"], ["typescript", "refactoring"] + Avoid generic tags like "code", "debugging", "work" + +------------------------------------- +【Source Action Handling】 +------------------------------------- +- Use `source` field to list the 1-based indexes of actions that belong to this activity +- Preserve action order (chronological) +- Every action must be assigned to exactly one activity (no overlap, no omission) + +------------------------------------- +【Quality Constraints】 +------------------------------------- +- Each activity should represent ≥5 minutes of focused work +- Avoid micro-activities (e.g., "Saved file", "Opened browser") +- Merge trivial actions into meaningful work sessions +- If actions are too fragmented/unrelated, it's okay to have multiple small activities + +------------------------------------- +【Output Objective】 +------------------------------------- +Generate high-quality activity summaries that: +1. Accurately reflect the user's work flow and accomplishments +2. Are useful for time tracking and work review +3. Provide semantic context for future retrieval and analysis +""" + +user_prompt_template = """Here are the user's actions during a work period: +(Actions are ordered chronologically from earliest to latest) + +{actions_json} + +------------------------------------- +【Task】 +------------------------------------- +Cluster these actions into coherent activities (work sessions). + +**KEY CONSTRAINT: Only one activity can exist in the same time period!** + +Consider (in priority order): +1. **Time Uniqueness** (HIGHEST PRIORITY): Absolutely PROHIBIT creating overlapping activities + - Check each activity's time range to ensure no overlap + - Actions close in time should be merged even if themes differ + - If user does multiple things simultaneously, choose the primary activity as title and mention others in description +2. **Thematic relevance** (HIGH PRIORITY): Same goal/project +3. **Time continuity** (HIGH PRIORITY): Actions close in time +4. **Workflow coherence**: Logical progression +5. **Context switches**: Different projects/apps/domains + +**Example (WRONG)**: +❌ Activity 1: 00:10-00:20 "Watch technical video" +❌ Activity 2: 00:15-00:25 "Debug system" +(These activities overlap in time and MUST be merged!) + +**Example (CORRECT)**: +✅ Activity: 00:10-00:25 "Debug system while referencing technical video tutorial" +(Primary activity is debugging, watching video is auxiliary and mentioned in description) + +------------------------------------- +【Output Format】 +------------------------------------- +Return ONLY the following JSON structure: + +```json +{{ + "activities": [ + {{ + "title": "string", + "description": "string", + "topic_tags": ["string"], + "source": [1, 2, 3] + }} + ] +}} +``` + +Where: +- `source`: 1-based indexes of actions that belong to this activity +- Every action index (1 to N) must appear exactly once across all activities +- Activities are ordered chronologically by their earliest action +- **MUST ensure no time overlap between activities** +""" + +[config.action_aggregation] +temperature = 0.3 +max_tokens = 4000 + [prompts.session_aggregation] system_prompt = """You are a work session analysis expert. Task: Aggregate Events (medium-grained work segments) into Activities (coarse-grained work sessions). @@ -1994,10 +2156,29 @@ system_prompt = """You are a professional activity (work session) quality review -------------------------------------- 【Review Criteria】(Ordered by Priority) -------------------------------------- -1. **Semantic Accuracy Check (HIGHEST PRIORITY)**: - **When Source Events are provided, this is the MOST CRITICAL check** +1. **Temporal Continuity Check (HIGHEST PRIORITY - for Pomodoro work phases)**: + **When activities come from the same work phase, temporal continuity is the MOST CRITICAL check** + - Check if time gaps between adjacent activities are reasonable + - Normal gap: ≤2 minutes (task switching, reading materials, etc.) + - Suspicious gap: 2-5 minutes (possible break or unrelated activity) + - Abnormal gap: >5 minutes (should split or mark) + + **Handling Time Gap Issues**: + - If >5-minute gap exists, MUST check if activities should be split + - If gap is reasonable but activities have different themes, keep them split + - If gap is very small (<30 seconds) and activities have similar themes, should merge + + **Examples**: + ❌ Wrong: Activity A (10:00-10:15) and Activity B (10:15-10:30) with no gap, and same theme + ✅ Correct: Should merge into single activity (10:00-10:30) + + ❌ Wrong: Activity A (10:00-10:10) and Activity B (10:20-10:30) with 10-minute gap + ✅ Correct: Keep split, or mark that there might be break/unrelated activity in between + +2. **Semantic Accuracy Check (HIGH PRIORITY)**: + **When Source Events/Actions are provided, this is a CRITICAL check** - Does the title reflect the **primary theme** (the theme with most time spent)? - - Analyze time distribution across all Source Events, calculate time spent per theme + - Analyze time distribution across all Source Events/Actions, calculate time spent per theme - If the title describes a minor theme (time ratio <40%), it MUST be corrected to the primary theme - With multiple themes, the title MUST only reflect the one consuming the most time - Description can mention other minor themes, but title MUST focus on the primary theme @@ -2009,32 +2190,32 @@ system_prompt = """You are a professional activity (work session) quality review ❌ Wrong: 5 events involving Frontend dev (total 50min) and Backend debugging (10min) → Title: "Full-Stack Development - Frontend-Backend Integration" ✅ Correct: Title should be: "Frontend Development - UI Component Implementation" (Backend debugging can be briefly mentioned in description) -2. **Title Quality Check**: +3. **Title Quality Check**: - Is the title within 20 characters? (character count) - Does it follow the "[Topic] - [Core Work]" format? - Does it avoid using semicolons (;) to separate multiple topics? - Does it avoid overly broad or vague descriptions (e.g., "Work processing", "Daily tasks")? - Does it describe a single work session, not multiple parallel sessions? -3. **Description Quality Check**: +4. **Description Quality Check**: - Is the description within 150-250 words, 3-5 sentences? - Does it use structured expression (bullet points or short paragraphs)? - Does it avoid lengthy narrative descriptions? - Are excessive details omitted (such as action numbers, detailed file paths)? - Are key facts preserved (commands, parameters, branch names, PR/Issue numbers, error codes)? -4. **Session Consistency Check**: +5. **Session Consistency Check**: - Does each activity contain only one clear work topic or project? - If it contains multiple unrelated topics, should it be split? - Are topic transition points correctly identified? - Do merged activities truly belong to the same work session? -5. **Information Density Check**: +6. **Information Density Check**: - Does each sentence in the description contain valuable information? - Are repetitive operation descriptions removed? - Are similar operations compressed using summary language? -6. **Deduplication Check**: +7. **Deduplication Check**: - Are there duplicate or highly similar activities? - Should similar activities be merged? @@ -2112,11 +2293,12 @@ Output the following JSON structure (no explanatory text): ``` **Review Priorities**: -1. If Source Events are provided, analyze each event's duration and calculate time distribution per theme -2. Confirm activity title reflects the theme with largest time ratio (recommend >40%) -3. Strictly check title length (MUST be ≤20 characters) and format -4. Check description length (MUST be within 150-250 words) -5. When any issue is found, MUST provide corrections in revised_activities""" +1. **Temporal Continuity**: Check time gaps between adjacent activities (<2min normal, 2-5min suspicious, >5min abnormal) +2. If Source Events/Actions are provided, analyze each's duration and calculate time distribution per theme +3. Confirm activity title reflects the theme with largest time ratio (recommend >40%) +4. Strictly check title length (MUST be ≤20 characters) and format +5. Check description length (MUST be within 150-250 words) +6. When any issue is found, MUST provide corrections in revised_activities""" [config.activity_supervisor] max_tokens = 2000 @@ -2393,3 +2575,287 @@ Think carefully and output only the following JSON structure (no explanatory tex ] }} ```""" + +[prompts.focus_score_evaluation] +system_prompt = """You are a focus evaluation expert who can comprehensively assess focus quality based on user work activity data. + +Your task is to analyze activity records from a Pomodoro work session, evaluate focus from multiple dimensions, and provide a 0-100 score with detailed analysis and suggestions. + +------------------------------------- +【Evaluation Dimensions】 +------------------------------------- +Analyze focus from these 5 dimensions: + +1. **Topic Consistency** - Weight: 30% + - Whether activities revolve around a single or closely related topics + - Topic switching frequency and reasonableness + - Correlation between multiple topics (do they serve the same goal) + +2. **Duration Depth** - Weight: 25% + - Duration of individual activities + - Presence of deep work sessions (>15 minutes) + - Reasonableness of time distribution + +3. **Switching Rhythm** - Weight: 20% + - Whether activity switching frequency is reasonable + - Presence of overly frequent task switching (<5 minutes) + - Whether switches have clear phase-based reasons + +4. **Work Quality** - Weight: 15% + - Whether activity descriptions show clear work outcomes + - Substantial progress (coding, writing, analysis, etc.) + - Obvious distraction behaviors (entertainment, chatting, etc.) + +5. **Goal Orientation** - Weight: 10% + - Whether activities have clear goals and direction + - Advancing specific tasks vs aimless browsing + - Consistency between actions and expected goals + +------------------------------------- +【Scoring Standards】 +------------------------------------- +Based on comprehensive evaluation of the 5 dimensions, provide 0-100 focus score: + +- **90-100 (Excellent)**: Highly focused, single-topic deep work, minimal distraction +- **80-89 (Very Good)**: Very good focus, clear topic, occasional reasonable switches +- **70-79 (Good)**: Generally focused, some topic switches but within control +- **60-69 (Moderate)**: Average focus, considerable topic switches or distractions +- **50-59 (Poor)**: Insufficient focus, frequent switches or obvious distractions +- **0-49 (Very Poor)**: Severe lack of focus, excessive distractions or no clear goal + +------------------------------------- +【Evaluation Principles】 +------------------------------------- +1. **Context Understanding**: Fully understand logical connections between activities +2. **Reasonableness Judgment**: Some "switches" may be work-necessary (checking docs, testing, review) +3. **Holistic Perspective**: Evaluate from entire work session perspective +4. **Encourage Depth**: Give higher evaluation to long-term deep work +5. **Tolerate Necessary Switches**: Development, writing inherently require switching between tools/resources +6. **Identify Real Distractions**: Focus on entertainment, chatting unrelated to work goals + +------------------------------------- +【Special Scenarios】 +------------------------------------- +- **Development**: Switching between editor, terminal, browser(docs), testing tools is normal +- **Writing**: Switching between documents, materials, search engines is necessary +- **Learning**: Video learning + note-taking + practice is reasonable multi-tasking +- **Design**: Switching between design tools, references, previews is normal +- **Break Time**: If explicitly marked as break, handle separately without affecting work session score + +------------------------------------- +【Output Format】 +------------------------------------- +After careful analysis, output the following JSON structure (no other text): + +```json +{ + "focus_score": 85, + "focus_level": "excellent", + "dimension_scores": { + "topic_consistency": 90, + "duration_depth": 85, + "switching_rhythm": 80, + "work_quality": 85, + "goal_orientation": 88 + }, + "analysis": { + "strengths": [ + "Maintained 25 minutes of focus watching Bilibili anime, showing good entertainment focus", + "Highly consistent topic, entire session around Log Horizon" + ], + "weaknesses": [ + "Activity type is entertainment rather than work/learning, focus quality needs goal context" + ], + "suggestions": [ + "If this is planned break time, focus performance is good", + "If this is work session, suggest adjusting activity type back to work tasks" + ] + }, + "work_type": "entertainment", + "is_focused_work": false, + "distraction_percentage": 0, + "deep_work_minutes": 0, + "context_summary": "User watched Log Horizon anime for 50 minutes across two viewing activities, showing high focus in entertainment context." +} +``` + +Field descriptions: +- `focus_score`: Integer score 0-100 +- `focus_level`: "excellent"(>=80) | "good"(60-79) | "moderate"(40-59) | "low"(<40) +- `dimension_scores`: 0-100 scores for each dimension +- `strengths`: Focus strengths (2-4 items) +- `weaknesses`: Focus weaknesses (1-3 items, can be empty) +- `suggestions`: Improvement suggestions (2-4 items) +- `work_type`: "development" | "writing" | "learning" | "research" | "design" | "communication" | "entertainment" | "productivity_analysis" | "mixed" | "unclear" +- `is_focused_work`: Whether it is high-quality focused work +- `distraction_percentage`: Distraction time percentage (0-100) +- `deep_work_minutes`: Deep work duration (minutes) +- `context_summary`: Brief summary of overall work situation (1-2 sentences) +""" + +user_prompt_template = """Please evaluate the focus of the following Pomodoro work session: + +**Session Information** +- Time Period: {start_time} - {end_time} +- Total Duration: {total_duration} minutes +- Activity Count: {activity_count} +- Topic Tags: {topic_tags} + +**Activity Details** +{activities_detail} + +Please comprehensively evaluate the focus quality of this work session based on the above information.""" + +[prompts.activity_focus_evaluation] +system_prompt = """You are a focus evaluation expert who can assess the focus quality of individual work activities. + +Your task is to analyze a single activity record and evaluate its focus quality based on task clarity, duration quality, work substance, and goal directedness. Provide a 0-100 score with reasoning. + +------------------------------------- +【Evaluation Dimensions】 +------------------------------------- +Analyze focus from these 4 dimensions: + +1. **Task Clarity** - Weight: 30% + - How specific and well-defined is the task? + - Clear action verbs and concrete objects vs vague descriptions + - Whether the activity title and description are informative + +2. **Duration Quality** - Weight: 30% + - Is the duration appropriate for the task type? + - Too short (<2 min) may indicate distraction or task-switching + - Longer durations (>10 min) often indicate sustained focus + - Consider task nature (quick lookup vs deep work) + +3. **Work Substance** - Weight: 25% + - Evidence of progress or concrete outcomes + - Substantial work (coding, writing, analysis) vs passive browsing + - Clear work artifacts vs entertainment/social activities + +4. **Goal Directedness** - Weight: 15% + - Does the activity have a clear purpose? + - Advancing specific goals vs aimless exploration + - Connection to larger work objectives + +------------------------------------- +【Scoring Standards】 +------------------------------------- +Based on comprehensive evaluation of the 4 dimensions, provide 0-100 focus score: + +- **90-100 (Excellent)**: Highly focused, clear goal, substantial work, appropriate duration +- **80-89 (Very Good)**: Very good focus, clear task, meaningful progress +- **70-79 (Good)**: Generally focused work with minor issues (too short, somewhat vague) +- **60-69 (Moderate)**: Moderate focus, unclear goal or minimal substance +- **50-59 (Poor)**: Insufficient focus, very short duration or unclear purpose +- **0-49 (Very Poor)**: Distraction, entertainment, or no clear work goal + +------------------------------------- +【Evaluation Principles】 +------------------------------------- +1. **Context Awareness**: Consider the type of work and typical patterns +2. **Duration Judgment**: Short activities aren't always bad (quick fixes, lookups are valid) +3. **Substance Over Form**: Prioritize evidence of actual work over activity length +4. **Goal Recognition**: Identify whether the activity advances work objectives +5. **Work Type Distinction**: Development, writing, learning have different patterns + +------------------------------------- +【Special Scenarios】 +------------------------------------- +- **Quick Lookups**: Short duration (1-3 min) is normal for documentation checks +- **Deep Work**: Long duration (>15 min) in coding/writing indicates excellent focus +- **Task Switching**: Rapid switches between related tools (editor→terminal→browser) can be productive +- **Entertainment**: Social media, videos, games should receive low scores unless work-related +- **Communication**: Work-related chat/meetings are valid, social chat is not + +------------------------------------- +【Output Format】 +------------------------------------- +Output the following JSON structure (no other text): + +```json +{ + "focus_score": 85, + "reasoning": "Clear implementation task with appropriate 18-minute duration showing sustained focus. Concrete outcome (authentication middleware) with specific technical details. Strong task clarity and work substance.", + "work_type": "development", + "is_productive": true +} +``` + +Field descriptions: +- `focus_score`: Integer score 0-100 +- `reasoning`: Brief explanation of the score (2-3 sentences) +- `work_type`: "development" | "writing" | "learning" | "research" | "design" | "communication" | "entertainment" | "productivity_analysis" | "mixed" | "unclear" +- `is_productive`: Boolean indicating whether this is productive work vs distraction +""" + +user_prompt_template = """Please evaluate the focus quality of the following activity: + +**Activity Information** +- Title: {title} +- Description: {description} +- Duration: {duration_minutes} minutes +- Topics: {topics} +- Action Count: {action_count} + +**Activity Actions** +{actions_summary} + +Based on the above information, evaluate this activity's focus quality and provide a score.""" + +[prompts.knowledge_merge_analysis] +system_prompt = """You are a knowledge management expert specializing in identifying and merging similar content. + +Your task is to analyze a list of knowledge entries and identify groups that should be merged based on: +1. **Content similarity**: Similar topics, concepts, or information +2. **Semantic overlap**: Redundant or overlapping descriptions +3. **Consistency**: Entries that describe the same thing in different ways + +Guidelines: +- Only suggest merging when similarity score is above the threshold +- Preserve all unique information when creating merged descriptions +- **REFINE keywords**: Select only 2-4 most essential, representative tags from all entries (avoid redundancy, keep core concepts) +- Provide clear reasons for why entries should be merged +- If entries are distinct despite similar keywords, keep them separate""" + +user_prompt_template = """Analyze the following knowledge entries for similarity and suggest merge operations. + +**Knowledge Entries:** +```json +{knowledge_json} +``` + +**Similarity Threshold:** {threshold} + +**Instructions:** +1. Group entries with similarity score >= {threshold} +2. For each group, generate: + - Merged title (concise, captures all content) + - Merged description (comprehensive, preserves all unique info) + - **Refined keywords** (2-4 most essential, representative tags - NO redundancy) + - Similarity score (0.0-1.0) + - Merge reason (brief explanation) + +**Response Format (JSON only):** +```json +{{ + "merge_clusters": [ + {{ + "knowledge_ids": ["id1", "id2"], + "merged_title": "...", + "merged_description": "...", + "merged_keywords": ["tag1", "tag2"], + "similarity_score": 0.85, + "merge_reason": "Both entries describe..." + }} + ] +}} +``` + +**Important**: Keep merged_keywords concise (2-4 tags max). Select only the most essential, representative tags from the group. Avoid redundant or overly specific tags. + +If no similar entries found, return: {{"merge_clusters": []}}""" + +[config.knowledge_merge_analysis] +max_tokens = 4000 +temperature = 0.3 + diff --git a/backend/config/prompts_zh.toml b/backend/config/prompts_zh.toml index 4f21fa1..93642d9 100644 --- a/backend/config/prompts_zh.toml +++ b/backend/config/prompts_zh.toml @@ -103,6 +103,30 @@ system_prompt = """你是用户桌面活动理解与动作抽取的专家。你 - 若多个相似操作被拆分成多个动作 → 合并为一个动作; - 若 `keywords` 含泛词(如"代码""浏览器""文档") → 替换。 +------------------------------------- +【行为上下文解读】 +------------------------------------- +系统基于键盘鼠标模式提供行为分类: + +**操作模式(主动工作):** +- 高频键盘活动(频繁打字、快捷键) +- 精确的鼠标点击和拖拽 +- 用户正在主动创建、编码、写作或设计 +- 动作应聚焦于:构建/编写/创建的内容、技术细节、问题解决 + +**浏览模式(被动消费):** +- 低频键盘活动(极少打字) +- 持续滚动、很少点击 +- 用户正在消费内容(阅读、观看、学习) +- 动作应聚焦于:学习/研究的内容、关键主题、信息来源 + +**混合模式:** +- 两种模式的组合 +- 用户可能在创作和参考之间切换 +- 动作应捕获两方面内容 + +**重要:** 行为上下文仅作为提示,非严格约束。截图的视觉证据优先。 + ------------------------------------- 【输出目标】 ------------------------------------- @@ -116,6 +140,9 @@ user_prompt_template = """这是感知到的用户最近的屏幕截图信息: 这是这段时间里面用户的输入感知状态和使用情况: {input_usage_hint} +**行为分类:** +{behavior_context} + **关于感知状态的重要说明:** - 如果键盘/鼠标感知被禁用,系统将无法捕获这些输入,因此你将无法获得该上下文信息。 - 当某种输入类型的感知被禁用时,请更多地依赖截图中的视觉线索来推断用户活动。 @@ -809,6 +836,141 @@ temperature = 0.5 max_tokens = 4000 temperature = 0.8 +[prompts.action_aggregation] +system_prompt = """你是工作会话理解和聚合领域的专家。 + +你的任务是将一系列细粒度的ACTIONS(操作记录)聚类为粗粒度的ACTIVITIES(工作会话),基于主题相关性、时间连续性和目标关联性。 + +------------------------------------- +【核心原则】 +------------------------------------- +1. **时间唯一性(最高优先级)**:同一时间段内只能有一个activity + - 如果多个actions在时间上重叠或接近,必须合并为一个activity + - 用户在同一时段做了多件事时,识别主要活动并在description中提及次要活动 + - 绝对禁止创建时间重叠的activities +2. **主题连贯性**:将服务于同一高层目标或项目的actions分组到一起 +3. **时间连续性**:时间间隔较短(≤5分钟)的actions通常属于同一会话 +4. **目标关联性**:不同对象/文件只要服务于同一工作目标就应该合并 +5. **项目一致性**:同一仓库/分支/功能模块表明是同一activity +6. **工作流连续性**:形成逻辑工作流的actions(编写 → 测试 → 调试 → 修复) + +------------------------------------- +【Activity粒度指导】 +------------------------------------- +- 一个activity = 围绕连贯主题的一次专注工作会话 +- 将在同一文件/功能/问题上的actions合并为一个activity +- 当切换到不同项目/目标/上下文时,分离为不同的activities +- 一个activity的示例: + - "实现用户认证功能"(包含编写代码、测试、调试、修复) + - "研究并实现Docker部署"(包含阅读文档、编写配置、排查问题) + - "调试支付网关集成"(包含分析日志、测试API、修复bug) +- 多个activities的示例: + - "实现认证功能" + "回复经理邮件" + "更新项目路线图" + +------------------------------------- +【Activity结构】 +------------------------------------- +- **title**: 工作会话的简洁总结(完成了什么) + 格式:`[动作动词] [对象/功能] ([上下文或目的])` + 示例: + - "实现JWT令牌的用户认证" + - "调试并修复Docker构建配置错误" + - "研究TypeScript泛型以重构API客户端" + +- **description**: 工作会话的完整叙述: + - 上下文:正在处理什么项目/功能/问题 + - 采取的行动:关键步骤的高层总结(不是详尽列表) + - 挑战:遇到的问题及解决方式 + - 结果:最终实现了什么 + +- **topic_tags**: 2-5个高层语义标签 + 示例:["认证", "后端"], ["docker", "部署"], ["typescript", "重构"] + 避免通用标签如"代码"、"调试"、"工作" + +------------------------------------- +【源Action处理】 +------------------------------------- +- 使用`source`字段列出属于此activity的actions的索引(从1开始) +- 保持action顺序(按时间顺序) +- 每个action必须且仅被分配给一个activity(不重叠、不遗漏) + +------------------------------------- +【质量约束】 +------------------------------------- +- 每个activity应该代表≥5分钟的专注工作 +- 避免微型activities(如"保存文件"、"打开浏览器") +- 将琐碎的actions合并为有意义的工作会话 +- 如果actions太分散/不相关,可以有多个小activities + +------------------------------------- +【输出目标】 +------------------------------------- +生成高质量的activity摘要,能够: +1. 准确反映用户的工作流程和成果 +2. 对时间跟踪和工作回顾有用 +3. 为未来检索和分析提供语义上下文 +""" + +user_prompt_template = """以下是用户在某个工作期间的actions: +(actions按时间顺序从早到晚排列) + +{actions_json} + +------------------------------------- +【任务】 +------------------------------------- +将这些actions聚类为连贯的activities(工作会话)。 + +**关键约束:同一时间段内只能有一个activity!** + +考虑因素(按优先级排序): +1. **时间唯一性**(最高优先级):绝对禁止创建时间重叠的activities + - 检查每个activity的时间范围,确保没有重叠 + - 时间接近的actions应该合并,即使主题不同 + - 如果用户同时做了多件事,选择主要活动作为title,其他活动在description中说明 +2. **主题相关性**(高优先级):相同目标/项目 +3. **时间连续性**(高优先级):actions时间接近 +4. **工作流连贯性**:逻辑递进 +5. **上下文切换**:不同项目/应用/领域 + +**示例(错误)**: +❌ Activity 1: 00:10-00:20 "观看技术视频" +❌ Activity 2: 00:15-00:25 "调试系统" +(这两个activity时间重叠,必须合并!) + +**示例(正确)**: +✅ Activity: 00:10-00:25 "调试系统并参考技术视频教程" +(主要活动是调试,观看视频是辅助活动,在description中说明) + +------------------------------------- +【输出格式】 +------------------------------------- +仅返回以下JSON结构: + +```json +{{ + "activities": [ + {{ + "title": "string", + "description": "string", + "topic_tags": ["string"], + "source": [1, 2, 3] + }} + ] +}} +``` + +其中: +- `source`: 属于此activity的actions的索引(从1开始) +- 每个action索引(1到N)必须在所有activities中恰好出现一次 +- Activities按其最早action的时间顺序排列 +- **必须保证activities之间没有时间重叠** +""" + +[config.action_aggregation] +temperature = 0.3 +max_tokens = 4000 + [prompts.session_aggregation] system_prompt = """你是工作会话分析专家。任务:将Events(中等粒度工作片段)聚合为Activities(粗粒度工作会话)。 @@ -1642,10 +1804,29 @@ system_prompt = """你是专业的活动(工作会话)质量审查专家。 -------------------------------------- 【审查标准】(按优先级排序) -------------------------------------- -1. **语义准确性检查**(最高优先级): - **当提供了Source Events时,这是最关键的检查项** +1. **时间连续性检查**(最高优先级 - 针对番茄钟工作阶段): + **当activities来自同一工作阶段时,时间连续性是最关键的检查项** + - 检查相邻activities之间的时间间隔是否合理 + - 正常间隔:≤2分钟(切换任务、查看资料等) + - 可疑间隔:2-5分钟(可能是休息或无关活动) + - 异常间隔:>5分钟(应该拆分或标记) + + **时间间隔问题处理**: + - 如果存在>5分钟的间隔,必须检查是否应该拆分为独立activities + - 如果间隔合理但activities主题不同,保持拆分状态 + - 如果间隔很小(<30秒)且activities主题相似,应该合并 + + **示例**: + ❌ 错误:Activity A (10:00-10:15) 和 Activity B (10:15-10:30) 之间无间隔,且主题相同 + ✅ 正确:应该合并为单个activity (10:00-10:30) + + ❌ 错误:Activity A (10:00-10:10) 和 Activity B (10:20-10:30) 之间有10分钟间隔 + ✅ 正确:保持拆分,或标记中间可能有休息/无关活动 + +2. **语义准确性检查**(高优先级): + **当提供了Source Events/Actions时,这是关键检查项** - 标题是否反映了**主要主题**(花费时间最多的主题)? - - 分析所有Source Events的时长分布,计算每个主题所占时间 + - 分析所有Source Events/Actions的时长分布,计算每个主题所占时间 - 如果标题描述的是次要主题(时间占比<40%),必须修正为主要主题 - 如果有多个主题,标题必须只反映占用时间最多的那个主题 - 描述可以提及其他次要主题,但标题必须聚焦主要主题 @@ -1657,32 +1838,32 @@ system_prompt = """你是专业的活动(工作会话)质量审查专家。 ❌ 错误:5个events涉及前端开发(共50分钟)和后端调试(10分钟)→ 标题:"全栈开发 - 前后端联调" ✅ 正确:标题应该是:"前端开发 - UI组件实现"(后端调试可以在描述中简单提及) -2. **标题质量检查**: +3. **标题质量检查**: - 标题是否控制在20字以内?(中文计数) - 是否遵循"[主题] - [核心工作内容]"格式? - 是否避免使用分号(;)分隔多个主题? - 是否避免过于宽泛或笼统的表述(如"工作处理"、"日常任务")? - 是否描述单一的工作会话,而非多个并列的会话? -3. **描述质量检查**: +4. **描述质量检查**: - 描述是否控制在150-250字,3-5句话? - 是否使用结构化表达(分点或简短段落)? - 是否避免了冗长的流水账描述? - 是否省略了过度细节(如action编号、详细文件路径)? - 是否保留了关键事实(命令、参数、分支名、PR/Issue号、错误代码)? -4. **会话一致性检查**: +5. **会话一致性检查**: - 每个activity是否只包含一个明确的工作主题或项目? - 如果包含多个不相关的主题,是否应该拆分? - 是否正确识别了主题切换点? - 合并的activities是否真的属于同一工作会话? -5. **信息密度检查**: +6. **信息密度检查**: - 描述的每句话是否包含有价值的信息? - 是否去除了重复性操作描述? - 是否用概括性语言压缩相似操作? -6. **去重检查**: +7. **去重检查**: - 是否存在重复或高度相似的activities? - 相似activities是否应该合并? @@ -1760,11 +1941,12 @@ user_prompt_template = """请审查以下Activity列表的质量: ``` **审查重点**: -1. 如果提供了Source Events,请务必分析每个event的时长,计算各主题的时间分布 -2. 确认activity标题反映的是时长占比最大的主题(建议>40%) -3. 严格检查标题长度(必须≤20字)和格式 -4. 检查描述长度(必须在150-250字之间) -5. 发现任何问题时,必须在revised_activities中提供修正版本""" +1. **时间连续性**:检查相邻activities的时间间隔(<2分钟正常,2-5分钟可疑,>5分钟异常) +2. 如果提供了Source Events/Actions,请务必分析每个的时长,计算各主题的时间分布 +3. 确认activity标题反映的是时长占比最大的主题(建议>40%) +4. 严格检查标题长度(必须≤20字)和格式 +5. 检查描述长度(必须在150-250字之间) +6. 发现任何问题时,必须在revised_activities中提供修正版本""" [config.activity_supervisor] max_tokens = 2000 @@ -2039,3 +2221,294 @@ user_prompt_template = """这里是来自用户最近活动的结构化场景描 ] }} ```""" + +[prompts.focus_score_evaluation] +system_prompt = """你是专注度评估专家,能够基于用户的工作活动数据,综合评估其专注度质量。 + +你的任务是分析一个番茄钟工作阶段的活动记录,从多个维度评估用户的专注度,并给出0-100分的评分和详细的分析建议。 + +------------------------------------- +【评估维度】 +------------------------------------- +你需要从以下5个维度分析专注度: + +1. **主题一致性 (Topic Consistency)** - 权重:30% + - 评估活动是否围绕单一主题或密切相关的主题展开 + - 主题切换频率和切换的合理性 + - 多主题间的关联度(是否服务于同一大目标) + +2. **持续深度 (Duration Depth)** - 权重:25% + - 单个活动的持续时间 + - 是否有足够长的深度工作时段(>15分钟) + - 时间分布的合理性 + +3. **切换节奏 (Switching Rhythm)** - 权重:20% + - 活动切换的频率是否合理 + - 是否存在过于频繁的任务切换(<5分钟就切换) + - 切换是否有明确的阶段性原因 + +4. **工作质量 (Work Quality)** - 权重:15% + - 活动描述是否体现出明确的工作成果 + - 是否有实质性的进展(编码、写作、分析等) + - 是否存在明显的分心行为(娱乐、闲聊等) + +5. **目标导向 (Goal Orientation)** - 权重:10% + - 活动是否有明确的目标和方向 + - 是否在推进具体任务而非漫无目的浏览 + - 行动与预期目标的一致性 + +------------------------------------- +【评分标准】 +------------------------------------- +基于上述5个维度的综合评估,给出0-100分的专注度评分: + +- **90-100分 (卓越)**: 高度专注,单一主题深度工作,极少分心 +- **80-89分 (优秀)**: 专注度很好,主题明确,偶有合理切换 +- **70-79分 (良好)**: 基本专注,有少量主题切换但在可控范围 +- **60-69分 (中等)**: 专注度一般,存在较多主题切换或分心 +- **50-59分 (较差)**: 专注度不足,频繁切换或有明显分心 +- **0-49分 (差)**: 严重缺乏专注,大量分心或无明确目标 + +------------------------------------- +【评估原则】 +------------------------------------- +1. **上下文理解**: 充分理解活动之间的逻辑关联,不要机械判断 +2. **合理性判断**: 某些"切换"可能是工作必需(如查文档、测试、review) +3. **整体视角**: 从整个工作阶段的角度评估,而非孤立看单个活动 +4. **鼓励深度**: 对长时间深度工作给予更高评价 +5. **容忍必要切换**: 开发、写作等工作本身就需要在不同工具/资源间切换 +6. **识别真实分心**: 重点识别与工作目标无关的娱乐、闲聊等行为 + +------------------------------------- +【特殊场景处理】 +------------------------------------- +- **开发工作**: 在编辑器、终端、浏览器(查文档)、测试工具间切换是正常的,不应过度扣分 +- **写作工作**: 在文档、资料、搜索引擎间切换是必需的 +- **学习场景**: 视频学习+笔记记录+实践操作是合理的多任务组合 +- **设计工作**: 在设计工具、参考资料、预览之间切换是正常的 +- **休息时间**: 如果明确标注为休息,应单独处理,不影响工作时段评分 + +------------------------------------- +【输出格式】 +------------------------------------- +仔细分析后,输出以下JSON结构(无其他文字): + +```json +{ + "focus_score": 85, + "focus_level": "excellent", + "dimension_scores": { + "topic_consistency": 90, + "duration_depth": 85, + "switching_rhythm": 80, + "work_quality": 85, + "goal_orientation": 88 + }, + "analysis": { + "strengths": [ + "持续25分钟专注于Bilibili动漫观看,展现出良好的娱乐专注度", + "主题高度一致,全程围绕《记录的地平线》展开" + ], + "weaknesses": [ + "活动类型为娱乐而非工作学习,专注度质量需要结合工作目标评估" + ], + "suggestions": [ + "如果这是计划内的休息时间,专注度表现很好", + "如果这是工作时段,建议调整活动类型回归工作任务" + ] + }, + "work_type": "entertainment", + "is_focused_work": false, + "distraction_percentage": 0, + "deep_work_minutes": 0, + "context_summary": "用户在50分钟的时段内观看了《记录的地平线》动漫,包含两段观看活动,展现出娱乐场景下的高度专注。" +} +``` + +字段说明: +- `focus_score`: 0-100的整数评分 +- `focus_level`: "excellent"(>=80) | "good"(60-79) | "moderate"(40-59) | "low"(<40) +- `dimension_scores`: 各维度的0-100分评分 +- `strengths`: 专注度的优点(2-4条) +- `weaknesses`: 专注度的不足(1-3条,可为空数组) +- `suggestions`: 改进建议(2-4条) +- `work_type`: "development" | "writing" | "learning" | "research" | "design" | "communication" | "entertainment" | "productivity_analysis" | "mixed" | "unclear" +- `is_focused_work`: 是否为高质量的专注工作 +- `distraction_percentage`: 分心时间占比(0-100) +- `deep_work_minutes`: 深度工作时长(分钟) +- `context_summary`: 整体工作情况的简要总结(1-2句话) +""" + +user_prompt_template = """请评估以下番茄钟工作阶段的专注度: + +**工作阶段信息** +- 时段: {start_time} - {end_time} +- 总时长: {total_duration} 分钟 +- 活动数量: {activity_count} 个 +- 主题标签: {topic_tags} + +**活动详情** +{activities_detail} + +请根据上述信息,综合评估这个工作阶段的专注度质量。""" + +[prompts.activity_focus_evaluation] +system_prompt = """你是专注度评估专家,能够评估单个工作活动的专注度质量。 + +你的任务是分析一个独立的活动记录,基于任务清晰度、时长质量、工作实质和目标导向性评估其专注度。给出0-100分的评分及理由。 + +------------------------------------- +【评估维度】 +------------------------------------- +你需要从以下4个维度分析专注度: + +1. **任务清晰度 (Task Clarity)** - 权重:30% + - 任务是否具体明确、定义清楚? + - 是否使用明确的动作动词和具体对象,而非模糊描述 + - 活动标题和描述是否信息丰富 + +2. **时长质量 (Duration Quality)** - 权重:30% + - 时长是否与任务类型相匹配? + - 过短(<2分钟)可能表示分心或任务切换 + - 较长时长(>10分钟)通常表示持续专注 + - 需考虑任务性质(快速查询 vs 深度工作) + +3. **工作实质 (Work Substance)** - 权重:25% + - 是否有进展或具体成果的证据 + - 实质性工作(编码、写作、分析)vs 被动浏览 + - 明确的工作产物 vs 娱乐/社交活动 + +4. **目标导向 (Goal Directedness)** - 权重:15% + - 活动是否有明确的目的? + - 推进具体目标 vs 漫无目的的探索 + - 与用户工作目标和待办事项的关联性 + - 活动内容是否直接服务于用户声明的工作目标 + +------------------------------------- +【评分标准】 +------------------------------------- +基于上述4个维度的综合评估,给出0-100分的专注度评分: + +- **90-100分 (卓越)**: 高度专注,目标明确,实质性工作,时长合理 +- **80-89分 (优秀)**: 专注度很好,任务清晰,有意义的进展 +- **70-79分 (良好)**: 基本专注的工作,但有小问题(过短、略模糊) +- **60-69分 (中等)**: 中等专注度,目标不明确或实质内容少 +- **50-59分 (较差)**: 专注度不足,时长很短或目的不清 +- **0-49分 (差)**: 分心、娱乐或无明确工作目标 + +------------------------------------- +【评估原则】 +------------------------------------- +1. **上下文感知**: 考虑工作类型和典型模式 +2. **时长判断**: 短活动不总是坏事(快速修复、查询是有效的) +3. **实质优先**: 优先考虑实际工作的证据而非活动长度 +4. **目标识别**: 识别活动是否推进工作目标 +5. **工作类型区分**: 开发、写作、学习有不同的模式 +6. **目标契合度**: 当提供了用户工作目标或待办事项时,重点评估活动与目标的契合度。活动越贴合用户声明的工作目标,专注度评分应越高 + +------------------------------------- +【特殊场景处理】 +------------------------------------- +- **快速查询**: 文档查阅的短时长(1-3分钟)是正常的 +- **深度工作**: 编码/写作的长时长(>15分钟)表示优秀专注度 +- **任务切换**: 相关工具间的快速切换(编辑器→终端→浏览器)可以是高效的 +- **娱乐活动**: 社交媒体、视频、游戏应给低分,除非与工作相关 +- **沟通**: 工作相关的聊天/会议是有效的,社交闲聊则不是 + +------------------------------------- +【输出格式】 +------------------------------------- +输出以下JSON结构(无其他文字): + +```json +{ + "focus_score": 85, + "reasoning": "任务清晰,实现身份验证中间件,18分钟的时长展现持续专注。有具体成果(身份验证中间件)和技术细节。任务清晰度和工作实质都很强。", + "work_type": "development", + "is_productive": true +} +``` + +字段说明: +- `focus_score`: 0-100的整数评分 +- `reasoning`: 评分的简要解释(2-3句话) +- `work_type`: "development" | "writing" | "learning" | "research" | "design" | "communication" | "entertainment" | "productivity_analysis" | "mixed" | "unclear" +- `is_productive`: 布尔值,表示这是否为高效工作而非分心 +""" + +user_prompt_template = """请评估以下活动的专注度质量: + +**活动信息** +- 标题: {title} +- 描述: {description} +- 时长: {duration_minutes} 分钟 +- 主题: {topics} +- 动作数量: {action_count} + +**活动动作** +{actions_summary} + +**工作目标和上下文** +- 用户工作目标: {user_intent} +- 相关待办事项: +{related_todos} + +基于上述信息(特别是工作目标和待办事项),评估这个活动的专注度质量并给出评分。重点评估活动与用户工作目标的契合度。""" + +[prompts.knowledge_merge_analysis] +system_prompt = """你是一位知识管理专家,擅长识别和合并相似内容。 + +你的任务是分析一组知识条目,并根据以下标准识别应该合并的组: +1. **内容相似性**:相似的主题、概念或信息 +2. **语义重叠**:冗余或重叠的描述 +3. **一致性**:用不同方式描述同一事物的条目 + +指导原则: +- 只在相似度分数高于阈值时建议合并 +- 创建合并描述时保留所有独特信息 +- **精简关键词**:仅选择 2-4 个最重要、最有代表性的标签(避免冗余,保留核心概念) +- 提供清晰的合并理由 +- 如果条目虽然关键词相似但内容不同,保持分离""" + +user_prompt_template = """分析以下知识条目的相似性并建议合并操作。 + +**知识条目:** +```json +{knowledge_json} +``` + +**相似度阈值:** {threshold} + +**指令:** +1. 将相似度 >= {threshold} 的条目分组 +2. 为每组生成: + - 合并后的标题(简洁,涵盖所有内容) + - 合并后的描述(全面,保留所有独特信息) + - **精简关键词**(2-4 个最重要、最有代表性的标签 - 无冗余) + - 相似度分数 (0.0-1.0) + - 合并理由(简要说明) + +**响应格式(仅 JSON):** +```json +{{ + "merge_clusters": [ + {{ + "knowledge_ids": ["id1", "id2"], + "merged_title": "...", + "merged_description": "...", + "merged_keywords": ["标签1", "标签2"], + "similarity_score": 0.85, + "merge_reason": "这两个条目都描述了..." + }} + ] +}} +``` + +**重要提示**:merged_keywords 应保持简洁(最多 2-4 个标签)。仅选择组中最重要、最有代表性的标签。避免冗余或过于具体的标签。 + +如果没有找到相似条目,返回:{{"merge_clusters": []}}""" + +[config.knowledge_merge_analysis] +max_tokens = 4000 +temperature = 0.3 + diff --git a/backend/core/coordinator.py b/backend/core/coordinator.py index cc7a8e0..f9fe11d 100644 --- a/backend/core/coordinator.py +++ b/backend/core/coordinator.py @@ -5,7 +5,7 @@ import asyncio from datetime import datetime, timedelta -from typing import Any, Dict, Optional +from typing import Any, Dict, List, Optional from config.loader import get_config from core.db import get_db @@ -28,21 +28,37 @@ def __init__(self, config: Dict[str, Any]): config: Configuration dictionary """ self.config = config - self.processing_interval = config.get("monitoring.processing_interval", 30) - self.window_size = config.get("monitoring.window_size", 60) - self.capture_interval = config.get("monitoring.capture_interval", 1.0) + + # Access nested config correctly + monitoring_config = config.get("monitoring", {}) + self.processing_interval = monitoring_config.get("processing_interval", 30) + self.window_size = monitoring_config.get("window_size", 60) + self.capture_interval = monitoring_config.get("capture_interval", 1.0) + + # Event-driven processing configuration + self.enable_event_driven = True # Enable event-driven processing + self.processing_threshold = 20 # Trigger processing when 20+ records accumulated + self.fallback_check_interval = 300 # Fallback check every 5 minutes (when no events) + self._pending_records_count = 0 # Track pending records + self._process_trigger = asyncio.Event() # Event to trigger processing # Initialize managers (lazy import to avoid circular dependencies) self.perception_manager = None self.processing_pipeline = None self.action_agent = None self.raw_agent = None - self.event_agent = None + # DISABLED: EventAgent removed - using action-based aggregation only + # self.event_agent = None self.session_agent = None self.todo_agent = None self.knowledge_agent = None self.diary_agent = None self.cleanup_agent = None + self.pomodoro_manager = None + + # Pomodoro mode state + self.pomodoro_mode = False + self.current_pomodoro_session_id: Optional[str] = None # Running state self.is_running = False @@ -159,8 +175,9 @@ def _on_system_sleep(self) -> None: # Pause all agents try: - if self.event_agent: - self.event_agent.pause() + # DISABLED: EventAgent removed - using action-based aggregation only + # if self.event_agent: + # self.event_agent.pause() if self.session_agent: self.session_agent.pause() if self.cleanup_agent: @@ -179,8 +196,9 @@ def _on_system_wake(self) -> None: # Resume all agents try: - if self.event_agent: - self.event_agent.resume() + # DISABLED: EventAgent removed - using action-based aggregation only + # if self.event_agent: + # self.event_agent.resume() if self.session_agent: self.session_agent.resume() if self.cleanup_agent: @@ -232,6 +250,12 @@ def _init_managers(self): enable_adaptive_threshold=processing_config.get( "enable_adaptive_threshold", True ), + max_accumulation_time=processing_config.get( + "max_accumulation_time", 180 + ), + min_sample_interval=processing_config.get( + "min_sample_interval", 30.0 + ), ) if self.action_agent is None: @@ -249,16 +273,18 @@ def _init_managers(self): ) ) - if self.event_agent is None: - from agents.event_agent import EventAgent - - processing_config = self.config.get("processing", {}) - self.event_agent = EventAgent( - aggregation_interval=processing_config.get( - "event_aggregation_interval", 600 - ), - time_window_hours=processing_config.get("event_time_window_hours", 1), - ) + # DISABLED: EventAgent removed - using action-based aggregation only + # if self.event_agent is None: + # from agents.event_agent import EventAgent + # + # processing_config = self.config.get("processing", {}) + # self.event_agent = EventAgent( + # coordinator=self, + # aggregation_interval=processing_config.get( + # "event_aggregation_interval", 600 + # ), + # time_window_hours=processing_config.get("event_time_window_hours", 1), + # ) if self.session_agent is None: from agents.session_agent import SessionAgent @@ -315,6 +341,11 @@ def _init_managers(self): ), ) + if self.pomodoro_manager is None: + from core.pomodoro_manager import PomodoroManager + + self.pomodoro_manager = PomodoroManager(self) + # Link agents if self.processing_pipeline: # Link action_agent to pipeline for action extraction @@ -376,9 +407,10 @@ async def start(self) -> None: logger.error("Action agent initialization failed") raise Exception("Action agent initialization failed") - if not self.event_agent: - logger.error("Event agent initialization failed") - raise Exception("Event agent initialization failed") + # DISABLED: EventAgent removed - using action-based aggregation only + # if not self.event_agent: + # logger.error("Event agent initialization failed") + # raise Exception("Event agent initialization failed") if not self.session_agent: logger.error("Session agent initialization failed") @@ -401,15 +433,18 @@ async def start(self) -> None: raise Exception("Cleanup agent initialization failed") # Start all components in parallel (they are independent) + # NOTE: Perception manager is NOT started by default - it will be started + # when a Pomodoro session begins (Active mode strategy) logger.debug( - "Starting perception manager, processing pipeline, agents in parallel..." + "Starting processing pipeline and agents (perception will start with Pomodoro)..." ) start_time = datetime.now() await asyncio.gather( - self.perception_manager.start(), + # self.perception_manager.start(), # Disabled: starts with Pomodoro self.processing_pipeline.start(), - self.event_agent.start(), + # DISABLED: EventAgent removed - using action-based aggregation only + # self.event_agent.start(), self.session_agent.start(), self.diary_agent.start(), self.cleanup_agent.start(), @@ -420,6 +455,12 @@ async def start(self) -> None: f"All components started (took {elapsed:.2f}s)" ) + # Check for orphaned Pomodoro sessions from previous run + if self.pomodoro_manager: + orphaned_count = await self.pomodoro_manager.check_orphaned_sessions() + if orphaned_count > 0: + logger.info(f"✓ Recovered {orphaned_count} orphaned Pomodoro session(s)") + # Start scheduled processing loop self.is_running = True self._set_state(mode="running", error=None) @@ -473,9 +514,10 @@ async def stop(self, *, quiet: bool = False) -> None: await self.session_agent.stop() log("Session agent stopped") - if self.event_agent: - await self.event_agent.stop() - log("Event agent stopped") + # DISABLED: EventAgent removed - using action-based aggregation only + # if self.event_agent: + # await self.event_agent.stop() + # log("Event agent stopped") # Note: ActionAgent has no start/stop methods (it's stateless) @@ -501,92 +543,174 @@ async def stop(self, *, quiet: bool = False) -> None: self._last_processed_timestamp = None async def _processing_loop(self) -> None: - """Scheduled processing loop""" + """Event-driven processing loop with fallback polling""" try: - # First iteration has shorter delay, then use normal interval - first_iteration = True last_ttl_cleanup = datetime.now() # Track last TTL cleanup time + last_fallback_check = datetime.now() # Track last fallback check + + logger.info( + f"Processing loop started: event_driven={'enabled' if self.enable_event_driven else 'disabled'}, " + f"threshold={self.processing_threshold} records, " + f"fallback_interval={self.fallback_check_interval}s" + ) while self.is_running: - # First iteration starts quickly (100ms), then use configured interval - wait_time = 0.1 if first_iteration else self.processing_interval - await asyncio.sleep(wait_time) - - if not self.is_running: - break - - first_iteration = False - - # Skip processing if paused (system sleep) - if self.is_paused: - logger.debug("Coordinator paused, skipping processing cycle") - continue - - # Periodic TTL cleanup for memory-only images - now = datetime.now() - if (now - last_ttl_cleanup).total_seconds() >= self.processing_interval: - try: - if self.processing_pipeline and self.processing_pipeline.image_manager: - evicted = self.processing_pipeline.image_manager.cleanup_expired_memory_images() - if evicted > 0: - logger.debug(f"TTL cleanup: evicted {evicted} expired memory-only images") - last_ttl_cleanup = now - except Exception as e: - logger.error(f"TTL cleanup failed: {e}") - - if not self.perception_manager: - logger.error("Perception manager not initialized") - raise Exception("Perception manager not initialized") - - if not self.processing_pipeline: - logger.error("Processing pipeline not initialized") - raise Exception("Processing pipeline not initialized") - - # Fetch records newer than the last processed timestamp to avoid duplicates - end_time = datetime.now() - if self._last_processed_timestamp is None: - start_time = end_time - timedelta(seconds=self.processing_interval) - else: - start_time = self._last_processed_timestamp - - records = self.perception_manager.get_records_in_timeframe( - start_time, end_time - ) + try: + if self.enable_event_driven: + # Wait for event trigger OR fallback timeout + try: + await asyncio.wait_for( + self._process_trigger.wait(), + timeout=self.fallback_check_interval + ) + self._process_trigger.clear() + logger.debug("Processing triggered by event") + except asyncio.TimeoutError: + # Fallback check after timeout + now = datetime.now() + if (now - last_fallback_check).total_seconds() >= self.fallback_check_interval: + logger.debug(f"Fallback check after {self.fallback_check_interval}s timeout") + last_fallback_check = now + else: + continue + else: + # Legacy polling mode + await asyncio.sleep(self.processing_interval) + + if not self.is_running: + break + + # Skip processing if paused (system sleep) + if self.is_paused: + logger.debug("Coordinator paused, skipping processing cycle") + continue + + # Periodic TTL cleanup for memory-only images + now = datetime.now() + if (now - last_ttl_cleanup).total_seconds() >= self.processing_interval: + try: + if self.processing_pipeline and self.processing_pipeline.image_manager: + evicted = self.processing_pipeline.image_manager.cleanup_expired_memory_images() + if evicted > 0: + logger.debug(f"TTL cleanup: evicted {evicted} expired memory-only images") + last_ttl_cleanup = now + except Exception as e: + logger.error(f"TTL cleanup failed: {e}") + + if not self.perception_manager: + logger.error("Perception manager not initialized") + raise Exception("Perception manager not initialized") + + if not self.processing_pipeline: + logger.error("Processing pipeline not initialized") + raise Exception("Processing pipeline not initialized") + + # CRITICAL FIX: Check for expiring records BEFORE normal processing + # This prevents records from being auto-cleaned before they can be processed into actions + # Particularly important during Pomodoro mode when user may be idle (reading, thinking) + expiring_records = self.perception_manager.get_expiring_records() + if expiring_records and self._last_processed_timestamp: + # Filter out already processed records + expiring_records = [ + record + for record in expiring_records + if record.timestamp > self._last_processed_timestamp + ] + + if expiring_records: + logger.warning( + f"Found {len(expiring_records)} records about to expire, " + f"forcing processing to prevent data loss" + ) + # Force process expiring records immediately + result = await self.processing_pipeline.process_raw_records(expiring_records) + + # Update last processed timestamp + latest_record_time = max( + (record.timestamp for record in expiring_records), default=None + ) + if latest_record_time: + self._last_processed_timestamp = latest_record_time + + # Update statistics + self.stats["total_processing_cycles"] += 1 + self.stats["last_processing_time"] = datetime.now() + self._pending_records_count = 0 + + logger.info(f"Processed {len(expiring_records)} expiring records to prevent loss") + + # Fetch records newer than the last processed timestamp to avoid duplicates + end_time = datetime.now() + if self._last_processed_timestamp is None: + start_time = end_time - timedelta(seconds=self.processing_interval) + else: + start_time = self._last_processed_timestamp + + records = self.perception_manager.get_records_in_timeframe( + start_time, end_time + ) - if self._last_processed_timestamp is not None: - records = [ - record - for record in records - if record.timestamp > self._last_processed_timestamp - ] + if self._last_processed_timestamp is not None: + records = [ + record + for record in records + if record.timestamp > self._last_processed_timestamp + ] - if records: - logger.debug(f"Starting to process {len(records)} records") + if records: + logger.info(f"Processing {len(records)} records (triggered by: {'event' if self.enable_event_driven else 'polling'})") - # Process records - result = await self.processing_pipeline.process_raw_records(records) + # Reset pending count + self._pending_records_count = 0 - # Update last processed timestamp so future cycles skip these records - latest_record_time = max( - (record.timestamp for record in records), default=None - ) - if latest_record_time: - self._last_processed_timestamp = latest_record_time + # Process records + result = await self.processing_pipeline.process_raw_records(records) - # Update statistics - self.stats["total_processing_cycles"] += 1 - self.stats["last_processing_time"] = datetime.now() + # Update last processed timestamp so future cycles skip these records + latest_record_time = max( + (record.timestamp for record in records), default=None + ) + if latest_record_time: + self._last_processed_timestamp = latest_record_time - logger.debug( - f"Processing completed: {len(result.get('events', []))} events, {len(result.get('activities', []))} activities" - ) - else: - logger.debug("No new records to process") + # Update statistics + self.stats["total_processing_cycles"] += 1 + self.stats["last_processing_time"] = datetime.now() + + logger.debug( + f"Processing completed: {len(result.get('events', []))} events, {len(result.get('activities', []))} activities" + ) + # No "else" logging when no records to reduce noise + + except Exception as loop_error: + logger.error(f"Error in processing loop iteration: {loop_error}", exc_info=True) + # Continue running despite errors except asyncio.CancelledError: logger.debug("Processing loop cancelled") except Exception as e: - logger.error(f"Processing loop failed: {e}") + logger.error(f"Processing loop failed: {e}", exc_info=True) + + def notify_records_available(self, count: int = 1) -> None: + """ + Notify coordinator that new records are available (called by PerceptionManager) + + This enables event-driven processing instead of polling. + + Args: + count: Number of new records added + """ + if not self.enable_event_driven: + return + + self._pending_records_count += count + + # Trigger processing if threshold reached + if self._pending_records_count >= self.processing_threshold: + logger.debug( + f"Triggering processing: {self._pending_records_count} records >= threshold {self.processing_threshold}" + ) + self._process_trigger.set() def get_stats(self) -> Dict[str, Any]: """Get coordinator statistics""" @@ -596,7 +720,8 @@ def get_stats(self) -> Dict[str, Any]: perception_stats = {} processing_stats = {} action_agent_stats = {} - event_agent_stats = {} + # DISABLED: EventAgent removed - using action-based aggregation only + # event_agent_stats = {} session_agent_stats = {} todo_agent_stats = {} knowledge_agent_stats = {} @@ -611,8 +736,9 @@ def get_stats(self) -> Dict[str, Any]: if self.action_agent: action_agent_stats = self.action_agent.get_stats() - if self.event_agent: - event_agent_stats = self.event_agent.get_stats() + # DISABLED: EventAgent removed - using action-based aggregation only + # if self.event_agent: + # event_agent_stats = self.event_agent.get_stats() if self.session_agent: session_agent_stats = self.session_agent.get_stats() @@ -649,7 +775,8 @@ def get_stats(self) -> Dict[str, Any]: "perception": perception_stats, "processing": processing_stats, "action_agent": action_agent_stats, - "event_agent": event_agent_stats, + # DISABLED: EventAgent removed - using action-based aggregation only + # "event_agent": event_agent_stats, "session_agent": session_agent_stats, "todo_agent": todo_agent_stats, "knowledge_agent": knowledge_agent_stats, @@ -663,6 +790,163 @@ def get_stats(self) -> Dict[str, Any]: return {"error": str(e)} + async def enter_pomodoro_mode(self, session_id: str) -> None: + """ + Enter Pomodoro mode - start perception and disable continuous processing + + Changes: + 1. Start perception manager (if not already running) + 2. Stop processing_loop (cancel task) + 3. Set pomodoro_mode = True + 4. Set current_pomodoro_session_id + 5. Perception captures and tags records + 6. Records are saved to DB instead of processed + + Args: + session_id: Pomodoro session identifier + """ + logger.info(f"→ Entering Pomodoro mode: {session_id}") + + self.pomodoro_mode = True + self.current_pomodoro_session_id = session_id + + # Start perception manager if not already running + if self.perception_manager and not self.perception_manager.is_running: + try: + logger.info("Starting perception manager for Pomodoro mode...") + await self.perception_manager.start() + logger.info("✓ Perception manager started") + except Exception as e: + logger.error(f"Failed to start perception manager: {e}", exc_info=True) + raise + elif not self.perception_manager: + logger.error("Perception manager is None, cannot start") + else: + logger.debug("Perception manager already running") + + # Keep processing loop running - do NOT cancel it + # This allows Actions (30s) to continue normally + + # DISABLED: EventAgent removed - using action-based aggregation only + # # NEW: Pause EventAgent during Pomodoro mode (action-based aggregation) + # # We directly aggregate Actions → Activities, bypassing Events layer + # try: + # if self.event_agent: + # self.event_agent.pause() + # logger.debug("✓ EventAgent paused (using action-based aggregation)") + # except Exception as e: + # logger.error(f"Failed to pause EventAgent: {e}") + + # Pause SessionAgent (activity generation deferred until phase ends) + try: + if self.session_agent: + self.session_agent.pause() + logger.debug("✓ SessionAgent paused (activity generation deferred)") + except Exception as e: + logger.error(f"Failed to pause SessionAgent: {e}") + + # Notify perception manager of Pomodoro mode (for tagging records) + if self.perception_manager: + self.perception_manager.set_pomodoro_session(session_id) + + logger.info( + "✓ Pomodoro mode active - normal processing continues, " + "activity generation paused until session ends" + ) + + async def exit_pomodoro_mode(self) -> None: + """ + Exit Pomodoro mode - stop perception and trigger activity generation + + When Pomodoro ends: + - Stop perception manager + - Resume SessionAgent + - Trigger immediate activity aggregation for accumulated Events + """ + logger.info("→ Exiting Pomodoro mode") + + self.pomodoro_mode = False + session_id = self.current_pomodoro_session_id + self.current_pomodoro_session_id = None + + # Stop perception manager + if self.perception_manager and self.perception_manager.is_running: + try: + logger.debug("Stopping perception manager...") + await self.perception_manager.stop() + logger.debug("✓ Perception manager stopped") + except Exception as e: + logger.error(f"Failed to stop perception manager: {e}") + + # Processing loop is still running - no need to resume + + # DISABLED: EventAgent removed - using action-based aggregation only + # # Resume EventAgent (for Normal Mode event generation) + # try: + # if self.event_agent: + # self.event_agent.resume() + # logger.debug("✓ EventAgent resumed (Normal Mode event generation)") + # except Exception as e: + # logger.error(f"Failed to resume EventAgent: {e}") + + # Resume SessionAgent (no need to trigger aggregation, already done per work phase) + try: + if self.session_agent: + self.session_agent.resume() + logger.debug("✓ SessionAgent resumed (activities already aggregated per work phase)") + except Exception as e: + logger.error(f"Failed to resume SessionAgent: {e}") + + # Notify perception manager to exit Pomodoro mode + if self.perception_manager: + self.perception_manager.clear_pomodoro_session() + + logger.info(f"✓ Idle mode resumed - perception stopped (exited session: {session_id})") + + async def force_process_records(self, records: List[Any]) -> Dict[str, Any]: + """ + Force process records immediately (used for phase settlement) + + This method bypasses the normal processing loop and directly processes + records through the pipeline. Used to ensure no data loss during phase transitions. + + Args: + records: List of RawRecord objects to process + + Returns: + Processing result with events and activities count + """ + if not records: + logger.debug("No records to force process") + return {"processed": 0, "events": [], "activities": []} + + logger.info(f"Force processing {len(records)} records for phase settlement") + + try: + if not self.processing_pipeline: + logger.error("Processing pipeline not available") + return {"error": "Processing pipeline not available"} + + # Directly process through the pipeline + result = await self.processing_pipeline.process_raw_records(records) + + # Update last processed timestamp + if records: + latest_record_time = max(record.timestamp for record in records) + self._last_processed_timestamp = latest_record_time + + logger.info( + f"✓ Force processing completed: " + f"{len(result.get('events', []))} events, {len(result.get('activities', []))} activities" + ) + + return result + + except Exception as e: + logger.error(f"Failed to force process records: {e}", exc_info=True) + return {"error": str(e)} + + def get_coordinator() -> PipelineCoordinator: """Get global coordinator singleton""" global _coordinator diff --git a/backend/core/db/__init__.py b/backend/core/db/__init__.py index 9f3c36f..f812584 100644 --- a/backend/core/db/__init__.py +++ b/backend/core/db/__init__.py @@ -16,12 +16,16 @@ # Three-layer architecture repositories from .actions import ActionsRepository from .activities import ActivitiesRepository +from .activity_ratings import ActivityRatingsRepository from .base import BaseRepository from .conversations import ConversationsRepository, MessagesRepository from .diaries import DiariesRepository from .events import EventsRepository from .knowledge import KnowledgeRepository from .models import LLMModelsRepository +from .pomodoro_sessions import PomodoroSessionsRepository +from .pomodoro_work_phases import PomodoroWorkPhasesRepository +from .raw_records import RawRecordsRepository from .session_preferences import SessionPreferencesRepository from .settings import SettingsRepository from .todos import TodosRepository @@ -69,76 +73,41 @@ def __init__(self, db_path: Path): self.actions = ActionsRepository(db_path) self.session_preferences = SessionPreferencesRepository(db_path) + # Pomodoro feature repositories + self.pomodoro_sessions = PomodoroSessionsRepository(db_path) + self.work_phases = PomodoroWorkPhasesRepository(db_path) + self.raw_records = RawRecordsRepository(db_path) + + # Activity ratings repository + self.activity_ratings = ActivityRatingsRepository(db_path) + logger.debug(f"✓ DatabaseManager initialized with path: {db_path}") def _initialize_database(self): """ - Initialize database schema - create all tables and indexes + Initialize database schema using version-based migrations This is called automatically when DatabaseManager is instantiated. - It ensures all required tables and indexes exist. + It runs all pending migrations to ensure database is up to date. """ - import sqlite3 - - from core.sqls import migrations, schema + from migrations import MigrationRunner try: - conn = sqlite3.connect(str(self.db_path)) - cursor = conn.cursor() + # Create migration runner + runner = MigrationRunner(self.db_path) - # Create all tables - for table_sql in schema.ALL_TABLES: - cursor.execute(table_sql) - - # Create all indexes - for index_sql in schema.ALL_INDEXES: - cursor.execute(index_sql) - - # Run migrations for new columns - self._run_migrations(cursor) - - conn.commit() - conn.close() + # Run all pending migrations + executed_count = runner.run_migrations() - logger.debug(f"✓ Database schema initialized: {len(schema.ALL_TABLES)} tables, {len(schema.ALL_INDEXES)} indexes") + if executed_count > 0: + logger.info(f"✓ Database schema initialized: {executed_count} migration(s) executed") + else: + logger.debug("✓ Database schema up to date") except Exception as e: logger.error(f"Failed to initialize database schema: {e}", exc_info=True) raise - def _run_migrations(self, cursor): - """ - Run database migrations to add new columns to existing tables - - Args: - cursor: Database cursor - """ - import sqlite3 - - from core.sqls import migrations - - # List of migrations to run (column name, migration SQL) - migration_list = [ - ("actions.extract_knowledge", migrations.ADD_ACTIONS_EXTRACT_KNOWLEDGE_COLUMN), - ("actions.knowledge_extracted", migrations.ADD_ACTIONS_KNOWLEDGE_EXTRACTED_COLUMN), - ("knowledge.source_action_id", migrations.ADD_KNOWLEDGE_SOURCE_ACTION_ID_COLUMN), - ] - - for column_desc, migration_sql in migration_list: - try: - cursor.execute(migration_sql) - logger.info(f"✓ Migration applied: {column_desc}") - except sqlite3.OperationalError as e: - error_msg = str(e).lower() - # Column might already exist, which is fine - if "duplicate column" in error_msg or "already exists" in error_msg: - logger.debug(f"Column {column_desc} already exists, skipping") - else: - # Real error, log as warning but continue - logger.warning(f"Migration failed for {column_desc}: {e}") - except Exception as e: - # Unexpected error - logger.error(f"Unexpected error in migration for {column_desc}: {e}", exc_info=True) def get_connection(self): """ @@ -305,8 +274,9 @@ def get_db() -> DatabaseManager: config = get_config() - # Read database path from config.toml - configured_path = config.get("database.path", "") + # Read database path from config.toml (access nested config correctly) + database_config = config.get("database", {}) + configured_path = database_config.get("path", "") # If path is configured and not empty, use it; otherwise use default if configured_path and configured_path.strip(): @@ -380,6 +350,9 @@ def switch_database(new_db_path: str) -> bool: "LLMModelsRepository", "ActionsRepository", "SessionPreferencesRepository", + "PomodoroSessionsRepository", + "RawRecordsRepository", + "ActivityRatingsRepository", # Unified manager "DatabaseManager", # Global access functions diff --git a/backend/core/db/actions.py b/backend/core/db/actions.py index ae0ea98..fbe19b5 100644 --- a/backend/core/db/actions.py +++ b/backend/core/db/actions.py @@ -508,3 +508,90 @@ def get_all_referenced_image_hashes(self) -> set: except Exception as e: logger.error(f"Failed to get referenced image hashes: {e}", exc_info=True) return set() + + async def get_all_actions_with_screenshots( + self, limit: Optional[int] = None + ) -> List[Dict[str, Any]]: + """Get all actions that have screenshot references + + Used for image persistence health checks to validate that referenced + images actually exist on disk. + + Args: + limit: Maximum number of actions to return (None = unlimited) + + Returns: + List of {id, created_at, screenshots: [...hashes]} + """ + try: + query = """ + SELECT DISTINCT a.id, a.created_at + FROM actions a + INNER JOIN action_images ai ON a.id = ai.action_id + WHERE a.deleted = 0 + ORDER BY a.created_at DESC + """ + if limit: + query += f" LIMIT {limit}" + + with self._get_conn() as conn: + cursor = conn.execute(query) + rows = cursor.fetchall() + + actions = [] + for row in rows: + screenshots = await self._load_screenshots(row["id"]) + if screenshots: # Only include if has screenshots + actions.append({ + "id": row["id"], + "created_at": row["created_at"], + "screenshots": screenshots, + }) + + logger.debug( + f"Found {len(actions)} actions with screenshots" + + (f" (limit: {limit})" if limit else "") + ) + return actions + + except Exception as e: + logger.error(f"Failed to get actions with screenshots: {e}", exc_info=True) + return [] + + async def remove_screenshots(self, action_id: str) -> int: + """Remove all screenshot references from an action + + Deletes all entries in action_images table for the given action, + effectively clearing the image references while keeping the action itself. + + Args: + action_id: Action ID + + Returns: + Number of references removed + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + "SELECT COUNT(*) as count FROM action_images WHERE action_id = ?", + (action_id,), + ) + count = cursor.fetchone()["count"] + + conn.execute( + "DELETE FROM action_images WHERE action_id = ?", + (action_id,), + ) + conn.commit() + + logger.debug( + f"Removed {count} screenshot references from action {action_id}" + ) + return count + + except Exception as e: + logger.error( + f"Failed to remove screenshots from action {action_id}: {e}", + exc_info=True, + ) + raise diff --git a/backend/core/db/activities.py b/backend/core/db/activities.py index f82367e..21369f2 100644 --- a/backend/core/db/activities.py +++ b/backend/core/db/activities.py @@ -27,23 +27,62 @@ async def save( description: str, start_time: str, end_time: str, - source_event_ids: List[str], + source_event_ids: Optional[List[str]] = None, + source_action_ids: Optional[List[str]] = None, + aggregation_mode: str = "action_based", session_duration_minutes: Optional[int] = None, topic_tags: Optional[List[str]] = None, user_merged_from_ids: Optional[List[str]] = None, user_split_into_ids: Optional[List[str]] = None, + pomodoro_session_id: Optional[str] = None, + pomodoro_work_phase: Optional[int] = None, + focus_score: Optional[float] = None, ) -> None: - """Save or update an activity (work session)""" + """ + Save or update an activity (work session) + + Args: + activity_id: Unique activity ID + title: Activity title + description: Activity description + start_time: Activity start time (ISO format) + end_time: Activity end time (ISO format) + source_event_ids: List of event IDs (for event-based aggregation, deprecated) + source_action_ids: List of action IDs (for action-based aggregation, preferred) + aggregation_mode: 'event_based' or 'action_based' (default: 'action_based') + session_duration_minutes: Session duration in minutes + topic_tags: List of topic tags + user_merged_from_ids: IDs of activities merged by user + user_split_into_ids: IDs of activities split by user + pomodoro_session_id: Associated Pomodoro session ID + pomodoro_work_phase: Work phase number (1-4) + focus_score: Focus metric (0.0-1.0) + + Raises: + ValueError: If neither source_event_ids nor source_action_ids is provided + """ + # Validation: at least one source type must be provided + if not source_event_ids and not source_action_ids: + raise ValueError("Either source_event_ids or source_action_ids must be provided") + + # Auto-detect aggregation mode if source_action_ids is provided + if source_action_ids: + aggregation_mode = "action_based" + elif source_event_ids and not source_action_ids: + aggregation_mode = "event_based" + try: with self._get_conn() as conn: conn.execute( """ INSERT OR REPLACE INTO activities ( id, title, description, start_time, end_time, - source_event_ids, session_duration_minutes, topic_tags, + source_event_ids, source_action_ids, aggregation_mode, + session_duration_minutes, topic_tags, user_merged_from_ids, user_split_into_ids, + pomodoro_session_id, pomodoro_work_phase, focus_score, created_at, updated_at, deleted - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) """, ( activity_id, @@ -51,15 +90,20 @@ async def save( description, start_time, end_time, - json.dumps(source_event_ids), + json.dumps(source_event_ids) if source_event_ids else None, + json.dumps(source_action_ids) if source_action_ids else None, + aggregation_mode, session_duration_minutes, json.dumps(topic_tags) if topic_tags else None, json.dumps(user_merged_from_ids) if user_merged_from_ids else None, json.dumps(user_split_into_ids) if user_split_into_ids else None, + pomodoro_session_id, + pomodoro_work_phase, + focus_score, ), ) conn.commit() - logger.debug(f"Saved activity: {activity_id}") + logger.debug(f"Saved activity: {activity_id} (mode: {aggregation_mode})") except Exception as e: logger.error(f"Failed to save activity {activity_id}: {e}", exc_info=True) raise @@ -145,8 +189,10 @@ async def get_recent( cursor = conn.execute( f""" SELECT id, title, description, start_time, end_time, - source_event_ids, session_duration_minutes, topic_tags, + source_event_ids, source_action_ids, aggregation_mode, + session_duration_minutes, topic_tags, user_merged_from_ids, user_split_into_ids, + pomodoro_session_id, pomodoro_work_phase, focus_score, created_at, updated_at FROM activities WHERE {where_clause} @@ -170,8 +216,10 @@ async def get_by_id(self, activity_id: str) -> Optional[Dict[str, Any]]: cursor = conn.execute( """ SELECT id, title, description, start_time, end_time, - source_event_ids, session_duration_minutes, topic_tags, + source_event_ids, source_action_ids, aggregation_mode, + session_duration_minutes, topic_tags, user_merged_from_ids, user_split_into_ids, + pomodoro_session_id, pomodoro_work_phase, focus_score, created_at, updated_at FROM activities WHERE id = ? AND deleted = 0 @@ -208,8 +256,10 @@ async def get_by_ids(self, activity_ids: List[str]) -> List[Dict[str, Any]]: cursor = conn.execute( f""" SELECT id, title, description, start_time, end_time, - source_event_ids, session_duration_minutes, topic_tags, + source_event_ids, source_action_ids, aggregation_mode, + session_duration_minutes, topic_tags, user_merged_from_ids, user_split_into_ids, + pomodoro_session_id, pomodoro_work_phase, focus_score, created_at, updated_at FROM activities WHERE id IN ({placeholders}) AND deleted = 0 @@ -242,8 +292,10 @@ async def get_by_date( cursor = conn.execute( """ SELECT id, title, description, start_time, end_time, - source_event_ids, session_duration_minutes, topic_tags, + source_event_ids, source_action_ids, aggregation_mode, + session_duration_minutes, topic_tags, user_merged_from_ids, user_split_into_ids, + pomodoro_session_id, pomodoro_work_phase, focus_score, created_at, updated_at FROM activities WHERE deleted = 0 @@ -404,6 +456,13 @@ async def get_count_by_date(self) -> Dict[str, int]: def _row_to_dict(self, row) -> Dict[str, Any]: """Convert database row to dictionary""" + # Helper function to safely get column value + def safe_get(row, key, default=None): + try: + return row[key] + except (KeyError, IndexError): + return default + return { "id": row["id"], "title": row["title"], @@ -413,6 +472,10 @@ def _row_to_dict(self, row) -> Dict[str, Any]: "source_event_ids": json.loads(row["source_event_ids"]) if row["source_event_ids"] else [], + "source_action_ids": json.loads(safe_get(row, "source_action_ids")) + if safe_get(row, "source_action_ids") + else [], + "aggregation_mode": safe_get(row, "aggregation_mode", "action_based"), "session_duration_minutes": row["session_duration_minutes"], "topic_tags": json.loads(row["topic_tags"]) if row["topic_tags"] else [], "user_merged_from_ids": json.loads(row["user_merged_from_ids"]) @@ -421,6 +484,277 @@ def _row_to_dict(self, row) -> Dict[str, Any]: "user_split_into_ids": json.loads(row["user_split_into_ids"]) if row["user_split_into_ids"] else None, + "pomodoro_session_id": safe_get(row, "pomodoro_session_id"), + "pomodoro_work_phase": safe_get(row, "pomodoro_work_phase"), + "focus_score": safe_get(row, "focus_score"), "created_at": row["created_at"], "updated_at": row["updated_at"], } + + async def get_by_pomodoro_session( + self, session_id: str + ) -> List[Dict[str, Any]]: + """ + Get all activities associated with a Pomodoro session + + Args: + session_id: Pomodoro session ID + + Returns: + List of activity dictionaries, ordered by work phase and start time + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT id, title, description, start_time, end_time, + source_event_ids, source_action_ids, aggregation_mode, + session_duration_minutes, topic_tags, + pomodoro_session_id, pomodoro_work_phase, focus_score, + user_merged_from_ids, user_split_into_ids, + created_at, updated_at + FROM activities + WHERE pomodoro_session_id = ? AND deleted = 0 + ORDER BY pomodoro_work_phase ASC, start_time ASC + """, + (session_id,), + ) + rows = cursor.fetchall() + + activities = [self._row_to_dict(row) for row in rows] + + logger.debug( + f"Retrieved {len(activities)} activities for Pomodoro session {session_id}" + ) + + return activities + + except Exception as e: + logger.error( + f"Failed to get activities for Pomodoro session {session_id}: {e}", + exc_info=True, + ) + return [] + + async def find_unlinked_overlapping_activities( + self, + session_start_time: str, + session_end_time: str, + ) -> List[Dict[str, Any]]: + """ + Find activities that overlap with session time and have no pomodoro_session_id + + Overlap logic: activity overlaps if activity.start_time < session.end_time + AND activity.end_time > session.start_time + + Args: + session_start_time: Session start (ISO format) + session_end_time: Session end (ISO format) + + Returns: + List of unlinked activity dictionaries + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT id, title, description, start_time, end_time, + source_event_ids, source_action_ids, aggregation_mode, + session_duration_minutes, topic_tags, + pomodoro_session_id, pomodoro_work_phase, focus_score, + user_merged_from_ids, user_split_into_ids, + created_at, updated_at + FROM activities + WHERE deleted = 0 + AND pomodoro_session_id IS NULL + AND start_time < ? + AND end_time > ? + ORDER BY start_time ASC + """, + (session_end_time, session_start_time), + ) + rows = cursor.fetchall() + + activities = [self._row_to_dict(row) for row in rows] + + logger.debug( + f"Found {len(activities)} unlinked activities overlapping " + f"{session_start_time} - {session_end_time}" + ) + + return activities + + except Exception as e: + logger.error( + f"Failed to find overlapping activities: {e}", + exc_info=True, + ) + return [] + + async def link_activities_to_session( + self, + activity_ids: List[str], + session_id: str, + work_phase: Optional[int] = None, + ) -> int: + """ + Link multiple activities to a Pomodoro session + + Args: + activity_ids: List of activity IDs to link + session_id: Pomodoro session ID + work_phase: Optional work phase number (if known) + + Returns: + Number of activities linked + """ + try: + if not activity_ids: + return 0 + + placeholders = ",".join("?" * len(activity_ids)) + params = [session_id, work_phase] + activity_ids + + with self._get_conn() as conn: + cursor = conn.execute( + f""" + UPDATE activities + SET pomodoro_session_id = ?, + pomodoro_work_phase = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id IN ({placeholders}) + AND deleted = 0 + AND pomodoro_session_id IS NULL + """, + params, + ) + conn.commit() + linked_count = cursor.rowcount + + logger.info( + f"Linked {linked_count} activities to session {session_id}" + ) + + return linked_count + + except Exception as e: + logger.error( + f"Failed to link activities to session: {e}", + exc_info=True, + ) + raise + + async def delete_by_session_id(self, session_id: str) -> int: + """ + Soft delete all activities linked to a Pomodoro session + Used for cascade deletion when a session is deleted + + Args: + session_id: Pomodoro session ID + + Returns: + Number of activities deleted + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + UPDATE activities + SET deleted = 1, updated_at = CURRENT_TIMESTAMP + WHERE pomodoro_session_id = ? AND deleted = 0 + """, + (session_id,), + ) + conn.commit() + deleted_count = cursor.rowcount + + logger.info( + f"Cascade deleted {deleted_count} activities for session {session_id}" + ) + return deleted_count + + except Exception as e: + logger.error( + f"Failed to cascade delete activities for session {session_id}: {e}", + exc_info=True, + ) + raise + + async def update_focus_score(self, activity_id: str, focus_score: float) -> None: + """ + Update focus score for a specific activity + + Args: + activity_id: Activity ID + focus_score: Focus score (0.0-100.0) + """ + try: + # Ensure focus_score is within valid range + focus_score = max(0.0, min(100.0, focus_score)) + + with self._get_conn() as conn: + conn.execute( + """ + UPDATE activities + SET focus_score = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (focus_score, activity_id), + ) + conn.commit() + + logger.debug(f"Updated focus_score for activity {activity_id}: {focus_score}") + + except Exception as e: + logger.error( + f"Failed to update focus_score for activity {activity_id}: {e}", + exc_info=True, + ) + raise + + async def batch_update_focus_scores( + self, activity_scores: List[Dict[str, Any]] + ) -> int: + """ + Batch update focus scores for multiple activities + + Args: + activity_scores: List of dicts with 'activity_id' and 'focus_score' keys + + Returns: + Number of activities updated + """ + if not activity_scores: + return 0 + + try: + with self._get_conn() as conn: + updated_count = 0 + for item in activity_scores: + activity_id = item.get("activity_id") + focus_score = item.get("focus_score", 0.0) + + if not activity_id: + continue + + # Ensure focus_score is within valid range + focus_score = max(0.0, min(100.0, focus_score)) + + cursor = conn.execute( + """ + UPDATE activities + SET focus_score = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (focus_score, activity_id), + ) + updated_count += cursor.rowcount + + conn.commit() + + logger.info(f"Batch updated focus_scores for {updated_count} activities") + return updated_count + + except Exception as e: + logger.error(f"Failed to batch update focus_scores: {e}", exc_info=True) + raise diff --git a/backend/core/db/activity_ratings.py b/backend/core/db/activity_ratings.py new file mode 100644 index 0000000..6665271 --- /dev/null +++ b/backend/core/db/activity_ratings.py @@ -0,0 +1,199 @@ +""" +ActivityRatings Repository - Handles multi-dimensional activity ratings +Manages user ratings for activities across different dimensions (focus, productivity, etc.) +""" + +import uuid +from pathlib import Path +from typing import Any, Dict, List, Optional + +from core.logger import get_logger + +from .base import BaseRepository + +logger = get_logger(__name__) + + +class ActivityRatingsRepository(BaseRepository): + """Repository for managing activity ratings in the database""" + + def __init__(self, db_path: Path): + super().__init__(db_path) + + async def save_rating( + self, + activity_id: str, + dimension: str, + rating: int, + note: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Save or update a rating for an activity dimension + + Args: + activity_id: Activity ID + dimension: Rating dimension (e.g., 'focus_level', 'productivity') + rating: Rating value (1-5) + note: Optional note/comment + + Returns: + The saved rating record + + Raises: + ValueError: If rating is out of range (1-5) + """ + if not 1 <= rating <= 5: + raise ValueError(f"Rating must be between 1 and 5, got {rating}") + + try: + rating_id = str(uuid.uuid4()) + + with self._get_conn() as conn: + # Use INSERT OR REPLACE to handle updates + # SQLite will replace if (activity_id, dimension) already exists + conn.execute( + """ + INSERT INTO activity_ratings ( + id, activity_id, dimension, rating, note, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT(activity_id, dimension) + DO UPDATE SET + rating = excluded.rating, + note = excluded.note, + updated_at = CURRENT_TIMESTAMP + """, + (rating_id, activity_id, dimension, rating, note), + ) + conn.commit() + + # Fetch the saved rating + cursor = conn.execute( + """ + SELECT id, activity_id, dimension, rating, note, + created_at, updated_at + FROM activity_ratings + WHERE activity_id = ? AND dimension = ? + """, + (activity_id, dimension), + ) + row = cursor.fetchone() + + logger.debug( + f"Saved rating for activity {activity_id}, " + f"dimension {dimension}: {rating}" + ) + + if not row: + raise ValueError(f"Failed to retrieve saved rating") + + result = self._row_to_dict(row) + if not result: + raise ValueError(f"Failed to convert rating to dict") + + return result + + except Exception as e: + logger.error( + f"Failed to save rating for activity {activity_id}: {e}", + exc_info=True, + ) + raise + + async def get_ratings_by_activity( + self, activity_id: str + ) -> List[Dict[str, Any]]: + """ + Get all ratings for an activity + + Args: + activity_id: Activity ID + + Returns: + List of rating records + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT id, activity_id, dimension, rating, note, + created_at, updated_at + FROM activity_ratings + WHERE activity_id = ? + ORDER BY dimension + """, + (activity_id,), + ) + rows = cursor.fetchall() + return [self._row_to_dict(row) for row in rows] + + except Exception as e: + logger.error( + f"Failed to get ratings for activity {activity_id}: {e}", + exc_info=True, + ) + raise + + async def delete_rating(self, activity_id: str, dimension: str) -> None: + """ + Delete a specific rating + + Args: + activity_id: Activity ID + dimension: Rating dimension + """ + try: + with self._get_conn() as conn: + conn.execute( + """ + DELETE FROM activity_ratings + WHERE activity_id = ? AND dimension = ? + """, + (activity_id, dimension), + ) + conn.commit() + logger.debug( + f"Deleted rating for activity {activity_id}, dimension {dimension}" + ) + + except Exception as e: + logger.error( + f"Failed to delete rating for activity {activity_id}: {e}", + exc_info=True, + ) + raise + + async def get_average_ratings_by_dimension( + self, start_date: str, end_date: str + ) -> Dict[str, float]: + """ + Get average ratings by dimension for a date range + + Args: + start_date: Start date (YYYY-MM-DD) + end_date: End date (YYYY-MM-DD) + + Returns: + Dict mapping dimension to average rating + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT ar.dimension, AVG(ar.rating) as avg_rating + FROM activity_ratings ar + JOIN activities a ON ar.activity_id = a.id + WHERE DATE(a.start_time) >= ? AND DATE(a.start_time) <= ? + GROUP BY ar.dimension + """, + (start_date, end_date), + ) + rows = cursor.fetchall() + return {row[0]: row[1] for row in rows} + + except Exception as e: + logger.error( + f"Failed to get average ratings for date range {start_date} to {end_date}: {e}", + exc_info=True, + ) + raise diff --git a/backend/core/db/events.py b/backend/core/db/events.py index 6206803..e3de1dc 100644 --- a/backend/core/db/events.py +++ b/backend/core/db/events.py @@ -29,6 +29,7 @@ async def save( end_time: str, source_action_ids: List[str], version: int = 1, + pomodoro_session_id: Optional[str] = None, ) -> None: """Save or update an event""" try: @@ -37,8 +38,8 @@ async def save( """ INSERT OR REPLACE INTO events ( id, title, description, start_time, end_time, - source_action_ids, version, created_at, deleted - ) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 0) + source_action_ids, version, pomodoro_session_id, created_at, deleted + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, 0) """, ( event_id, @@ -48,10 +49,11 @@ async def save( end_time, json.dumps(source_action_ids), version, + pomodoro_session_id, ), ) conn.commit() - logger.debug(f"Saved event: {event_id}") + logger.debug(f"Saved event: {event_id}" + (f" (session: {pomodoro_session_id})" if pomodoro_session_id else "")) except Exception as e: logger.error(f"Failed to save event {event_id}: {e}", exc_info=True) raise diff --git a/backend/core/db/knowledge.py b/backend/core/db/knowledge.py index 00a9d01..8744726 100644 --- a/backend/core/db/knowledge.py +++ b/backend/core/db/knowledge.py @@ -29,6 +29,7 @@ async def save( *, created_at: Optional[str] = None, source_action_id: Optional[str] = None, + favorite: bool = False, ) -> None: """Save or update knowledge""" try: @@ -38,8 +39,8 @@ async def save( """ INSERT OR REPLACE INTO knowledge ( id, title, description, keywords, - source_action_id, created_at, deleted - ) VALUES (?, ?, ?, ?, ?, ?, 0) + source_action_id, created_at, deleted, favorite + ) VALUES (?, ?, ?, ?, ?, ?, 0, ?) """, ( knowledge_id, @@ -48,6 +49,7 @@ async def save( json.dumps(keywords, ensure_ascii=False), source_action_id, created, + int(favorite), ), ) conn.commit() @@ -66,6 +68,7 @@ async def save( "keywords": keywords, "created_at": created, "source_action_id": source_action_id, + "favorite": favorite, "type": "original", } ) @@ -89,7 +92,7 @@ async def get_list(self, include_deleted: bool = False) -> List[Dict[str, Any]]: with self._get_conn() as conn: cursor = conn.execute( f""" - SELECT id, title, description, keywords, source_action_id, created_at, deleted + SELECT id, title, description, keywords, source_action_id, created_at, deleted, favorite FROM knowledge {base_where} ORDER BY created_at DESC @@ -99,6 +102,12 @@ async def get_list(self, include_deleted: bool = False) -> List[Dict[str, Any]]: knowledge_list: List[Dict[str, Any]] = [] for row in rows: + # Handle favorite field which might not exist in older databases + try: + favorite = bool(row["favorite"]) + except (KeyError, IndexError): + favorite = False + knowledge_list.append( { "id": row["id"], @@ -110,6 +119,7 @@ async def get_list(self, include_deleted: bool = False) -> List[Dict[str, Any]]: "source_action_id": row["source_action_id"], "created_at": row["created_at"], "deleted": bool(row["deleted"]), + "favorite": favorite, } ) @@ -139,6 +149,47 @@ async def delete(self, knowledge_id: str) -> None: ) raise + async def update( + self, + knowledge_id: str, + title: str, + description: str, + keywords: List[str], + ) -> None: + """Update knowledge title, description, and keywords""" + try: + with self._get_conn() as conn: + conn.execute( + """ + UPDATE knowledge + SET title = ?, description = ?, keywords = ? + WHERE id = ? AND deleted = 0 + """, + ( + title, + description, + json.dumps(keywords, ensure_ascii=False), + knowledge_id, + ), + ) + conn.commit() + logger.debug(f"Updated knowledge: {knowledge_id}") + + # Send event to frontend + from core.events import emit_knowledge_updated + + emit_knowledge_updated({ + "id": knowledge_id, + "title": title, + "description": description, + "keywords": keywords, + }) + except Exception as e: + logger.error( + f"Failed to update knowledge {knowledge_id}: {e}", exc_info=True + ) + raise + async def delete_batch(self, knowledge_ids: List[str]) -> int: """Soft delete multiple knowledge rows""" if not knowledge_ids: @@ -188,6 +239,97 @@ async def delete_by_date_range(self, start_iso: str, end_iso: str) -> int: ) return 0 + async def hard_delete(self, knowledge_id: str) -> bool: + """Hard delete knowledge from database (permanent deletion)""" + try: + with self._get_conn() as conn: + cursor = conn.execute( + "DELETE FROM knowledge WHERE id = ?", (knowledge_id,) + ) + conn.commit() + + if cursor.rowcount > 0: + logger.debug(f"Hard deleted knowledge: {knowledge_id}") + # Send event to frontend + from core.events import emit_knowledge_deleted + emit_knowledge_deleted(knowledge_id) + return True + return False + except Exception as e: + logger.error( + f"Failed to hard delete knowledge {knowledge_id}: {e}", exc_info=True + ) + raise + + async def hard_delete_batch(self, knowledge_ids: List[str]) -> int: + """Hard delete multiple knowledge rows (permanent deletion)""" + if not knowledge_ids: + return 0 + + try: + placeholders = ",".join("?" for _ in knowledge_ids) + with self._get_conn() as conn: + cursor = conn.execute( + f"DELETE FROM knowledge WHERE id IN ({placeholders})", + knowledge_ids, + ) + conn.commit() + deleted_count = cursor.rowcount + + if deleted_count > 0: + logger.debug(f"Hard deleted {deleted_count} knowledge entries") + + return deleted_count + + except Exception as e: + logger.error(f"Failed to batch hard delete knowledge: {e}", exc_info=True) + return 0 + + async def toggle_favorite(self, knowledge_id: str) -> Optional[bool]: + """Toggle favorite status of knowledge + + Returns: + New favorite status (True/False) if successful, None if knowledge not found + """ + try: + with self._get_conn() as conn: + # Get current favorite status + cursor = conn.execute( + "SELECT favorite FROM knowledge WHERE id = ? AND deleted = 0", + (knowledge_id,) + ) + row = cursor.fetchone() + + if not row: + logger.warning(f"Knowledge {knowledge_id} not found or deleted") + return None + + current_favorite = bool(row["favorite"]) + new_favorite = not current_favorite + + # Update favorite status + conn.execute( + "UPDATE knowledge SET favorite = ? WHERE id = ?", + (int(new_favorite), knowledge_id) + ) + conn.commit() + + logger.debug(f"Toggled favorite for knowledge {knowledge_id}: {new_favorite}") + + # Send update event to frontend + from core.events import emit_knowledge_updated + + emit_knowledge_updated({ + "id": knowledge_id, + "favorite": new_favorite + }) + + return new_favorite + + except Exception as e: + logger.error(f"Failed to toggle favorite for knowledge {knowledge_id}: {e}", exc_info=True) + raise + async def get_count_by_date(self) -> Dict[str, int]: """ Get knowledge count grouped by date diff --git a/backend/core/db/pomodoro_sessions.py b/backend/core/db/pomodoro_sessions.py new file mode 100644 index 0000000..90d8115 --- /dev/null +++ b/backend/core/db/pomodoro_sessions.py @@ -0,0 +1,609 @@ +""" +PomodoroSessions Repository - Handles Pomodoro session lifecycle +Manages session metadata, status tracking, and processing state +""" + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional + +from core.logger import get_logger + +from .base import BaseRepository + +logger = get_logger(__name__) + + +class PomodoroSessionsRepository(BaseRepository): + """Repository for managing Pomodoro sessions in the database""" + + def __init__(self, db_path: Path): + super().__init__(db_path) + + async def create( + self, + session_id: str, + user_intent: str, + planned_duration_minutes: int, + start_time: str, + status: str = "active", + associated_todo_id: Optional[str] = None, + work_duration_minutes: int = 25, + break_duration_minutes: int = 5, + total_rounds: int = 4, + ) -> None: + """ + Create a new Pomodoro session + + Args: + session_id: Unique session identifier + user_intent: User's description of what they plan to work on + planned_duration_minutes: Planned session duration (total for all rounds) + start_time: ISO format start timestamp + status: Session status (default: 'active') + associated_todo_id: Optional TODO ID to associate with this session + work_duration_minutes: Duration of each work phase (default: 25) + break_duration_minutes: Duration of each break phase (default: 5) + total_rounds: Total number of work rounds (default: 4) + """ + try: + with self._get_conn() as conn: + conn.execute( + """ + INSERT INTO pomodoro_sessions ( + id, user_intent, planned_duration_minutes, + start_time, status, associated_todo_id, + work_duration_minutes, break_duration_minutes, total_rounds, + current_round, current_phase, phase_start_time, completed_rounds, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 'work', ?, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + ( + session_id, + user_intent, + planned_duration_minutes, + start_time, + status, + associated_todo_id, + work_duration_minutes, + break_duration_minutes, + total_rounds, + start_time, # phase_start_time = start_time initially + ), + ) + conn.commit() + logger.debug(f"Created Pomodoro session: {session_id}") + except Exception as e: + logger.error(f"Failed to create Pomodoro session {session_id}: {e}", exc_info=True) + raise + + async def update(self, session_id: str, **kwargs) -> None: + """ + Update Pomodoro session fields + + Args: + session_id: Session ID to update + **kwargs: Fields to update (e.g., end_time, status, processing_status) + """ + try: + if not kwargs: + return + + set_clauses = [] + params = [] + + for key, value in kwargs.items(): + set_clauses.append(f"{key} = ?") + params.append(value) + + set_clauses.append("updated_at = CURRENT_TIMESTAMP") + params.append(session_id) + + query = f""" + UPDATE pomodoro_sessions + SET {', '.join(set_clauses)} + WHERE id = ? + """ + + with self._get_conn() as conn: + conn.execute(query, params) + conn.commit() + logger.debug(f"Updated Pomodoro session {session_id}: {list(kwargs.keys())}") + except Exception as e: + logger.error(f"Failed to update Pomodoro session {session_id}: {e}", exc_info=True) + raise + + async def get_by_id(self, session_id: str) -> Optional[Dict[str, Any]]: + """ + Get session by ID + + Args: + session_id: Session ID + + Returns: + Session dictionary or None if not found + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT * FROM pomodoro_sessions + WHERE id = ? AND deleted = 0 + """, + (session_id,), + ) + row = cursor.fetchone() + return self._row_to_dict(row) + except Exception as e: + logger.error(f"Failed to get Pomodoro session {session_id}: {e}", exc_info=True) + raise + + async def get_by_status( + self, + status: str, + limit: int = 100, + offset: int = 0, + ) -> List[Dict[str, Any]]: + """ + Get sessions by status + + Args: + status: Session status ('active', 'completed', 'abandoned', etc.) + limit: Maximum number of results + offset: Number of results to skip + + Returns: + List of session dictionaries + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT * FROM pomodoro_sessions + WHERE status = ? AND deleted = 0 + ORDER BY start_time DESC + LIMIT ? OFFSET ? + """, + (status, limit, offset), + ) + rows = cursor.fetchall() + return self._rows_to_dicts(rows) + except Exception as e: + logger.error(f"Failed to get sessions by status {status}: {e}", exc_info=True) + raise + + async def get_by_processing_status( + self, + processing_status: str, + limit: int = 100, + offset: int = 0, + ) -> List[Dict[str, Any]]: + """ + Get sessions by processing status + + Args: + processing_status: Processing status ('pending', 'processing', 'completed', 'failed') + limit: Maximum number of results + offset: Number of results to skip + + Returns: + List of session dictionaries + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT * FROM pomodoro_sessions + WHERE processing_status = ? AND deleted = 0 + ORDER BY start_time DESC + LIMIT ? OFFSET ? + """, + (processing_status, limit, offset), + ) + rows = cursor.fetchall() + return self._rows_to_dicts(rows) + except Exception as e: + logger.error( + f"Failed to get sessions by processing status {processing_status}: {e}", + exc_info=True, + ) + raise + + async def get_recent( + self, + limit: int = 10, + offset: int = 0, + ) -> List[Dict[str, Any]]: + """ + Get recent Pomodoro sessions + + Args: + limit: Maximum number of results + offset: Number of results to skip + + Returns: + List of session dictionaries + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT * FROM pomodoro_sessions + WHERE deleted = 0 + ORDER BY start_time DESC + LIMIT ? OFFSET ? + """, + (limit, offset), + ) + rows = cursor.fetchall() + return self._rows_to_dicts(rows) + except Exception as e: + logger.error(f"Failed to get recent Pomodoro sessions: {e}", exc_info=True) + raise + + async def get_stats( + self, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + """ + Get Pomodoro session statistics + + Args: + start_date: Optional start date (ISO format) + end_date: Optional end date (ISO format) + + Returns: + Dictionary with statistics (total, completed, abandoned, avg_duration, etc.) + """ + try: + with self._get_conn() as conn: + where_clauses = ["deleted = 0"] + params = [] + + if start_date: + where_clauses.append("start_time >= ?") + params.append(start_date) + if end_date: + where_clauses.append("start_time <= ?") + params.append(end_date) + + where_sql = " AND ".join(where_clauses) + + cursor = conn.execute( + f""" + SELECT + COUNT(*) as total, + SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed, + SUM(CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END) as abandoned, + SUM(CASE WHEN status = 'interrupted' THEN 1 ELSE 0 END) as interrupted, + AVG(actual_duration_minutes) as avg_duration, + SUM(actual_duration_minutes) as total_duration + FROM pomodoro_sessions + WHERE {where_sql} + """, + params, + ) + row = cursor.fetchone() + return self._row_to_dict(row) if row else None + except Exception as e: + logger.error(f"Failed to get Pomodoro session stats: {e}", exc_info=True) + raise + + async def soft_delete(self, session_id: str) -> None: + """ + Soft delete a session + + Args: + session_id: Session ID to delete + """ + try: + with self._get_conn() as conn: + conn.execute( + """ + UPDATE pomodoro_sessions + SET deleted = 1, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (session_id,), + ) + conn.commit() + logger.debug(f"Soft deleted Pomodoro session: {session_id}") + except Exception as e: + logger.error(f"Failed to soft delete Pomodoro session {session_id}: {e}", exc_info=True) + raise + + async def hard_delete_old(self, days: int = 90) -> int: + """ + Hard delete old completed sessions + + Args: + days: Delete sessions older than this many days + + Returns: + Number of sessions deleted + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + DELETE FROM pomodoro_sessions + WHERE deleted = 1 + AND created_at < datetime('now', '-' || ? || ' days') + """, + (days,), + ) + conn.commit() + deleted_count = cursor.rowcount + logger.debug(f"Hard deleted {deleted_count} old Pomodoro sessions") + return deleted_count + except Exception as e: + logger.error(f"Failed to hard delete old sessions: {e}", exc_info=True) + raise + + async def update_todo_association( + self, session_id: str, todo_id: Optional[str] + ) -> None: + """ + Update the associated TODO for a Pomodoro session + + Args: + session_id: Session ID + todo_id: TODO ID to associate (None to clear association) + """ + try: + with self._get_conn() as conn: + conn.execute( + """ + UPDATE pomodoro_sessions + SET associated_todo_id = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (todo_id, session_id), + ) + conn.commit() + logger.debug( + f"Updated TODO association for session {session_id}: {todo_id}" + ) + except Exception as e: + logger.error( + f"Failed to update TODO association for session {session_id}: {e}", + exc_info=True, + ) + raise + + async def get_sessions_by_todo(self, todo_id: str) -> List[Dict[str, Any]]: + """ + Get all sessions associated with a TODO + + Args: + todo_id: TODO ID + + Returns: + List of session dictionaries + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT * FROM pomodoro_sessions + WHERE associated_todo_id = ? AND deleted = 0 + ORDER BY start_time DESC + """, + (todo_id,), + ) + rows = cursor.fetchall() + return self._rows_to_dicts(rows) + except Exception as e: + logger.error( + f"Failed to get sessions for TODO {todo_id}: {e}", exc_info=True + ) + raise + + async def get_daily_stats(self, date: str) -> Dict[str, Any]: + """ + Get Pomodoro statistics for a specific date + + Args: + date: Date in YYYY-MM-DD format + + Returns: + Dictionary with daily statistics: + - completed_count: Number of completed sessions + - total_focus_minutes: Total focus time in minutes + - average_duration_minutes: Average session duration + - sessions: List of sessions for that day + """ + try: + with self._get_conn() as conn: + # Get aggregated stats + cursor = conn.execute( + """ + SELECT + COUNT(*) as completed_count, + COALESCE(SUM(actual_duration_minutes), 0) as total_focus_minutes, + COALESCE(AVG(actual_duration_minutes), 0) as average_duration_minutes + FROM pomodoro_sessions + WHERE DATE(start_time) = ? + AND status = 'completed' + AND deleted = 0 + """, + (date,), + ) + stats_row = cursor.fetchone() + + # Get session list for the day (only completed sessions) + cursor = conn.execute( + """ + SELECT * FROM pomodoro_sessions + WHERE DATE(start_time) = ? + AND status = 'completed' + AND deleted = 0 + ORDER BY start_time DESC + """, + (date,), + ) + sessions = self._rows_to_dicts(cursor.fetchall()) + + return { + "completed_count": stats_row[0] if stats_row else 0, + "total_focus_minutes": int(stats_row[1]) if stats_row else 0, + "average_duration_minutes": int(stats_row[2]) if stats_row else 0, + "sessions": sessions, + } + except Exception as e: + logger.error(f"Failed to get daily stats for {date}: {e}", exc_info=True) + raise + + async def switch_phase( + self, session_id: str, new_phase: str, phase_start_time: str + ) -> Dict[str, Any]: + """ + Switch to next phase in Pomodoro session + + Phase transitions: + - work → break: Increment completed_rounds + - break → work: Increment current_round + - Automatically mark as completed if all rounds finished + + Args: + session_id: Session ID + new_phase: New phase ('work' or 'break') + phase_start_time: ISO timestamp when new phase starts + + Returns: + Updated session record + """ + try: + with self._get_conn() as conn: + # Get current session state + cursor = conn.execute( + """ + SELECT current_phase, current_round, completed_rounds, total_rounds + FROM pomodoro_sessions + WHERE id = ? + """, + (session_id,), + ) + row = cursor.fetchone() + if not row: + raise ValueError(f"Session {session_id} not found") + + current_phase, current_round, completed_rounds, total_rounds = row + + # Calculate new state based on phase transition + if current_phase == "work" and new_phase == "break": + # Completed a work phase + completed_rounds += 1 + # current_round stays the same during break + + elif current_phase == "break" and new_phase == "work": + # Starting next work round + current_round += 1 + + # Check if all rounds are completed + new_status = "active" + if completed_rounds >= total_rounds and new_phase == "break": + # After completing the last work round, mark as completed + new_status = "completed" + new_phase = "completed" + + # Update session + conn.execute( + """ + UPDATE pomodoro_sessions + SET current_phase = ?, + current_round = ?, + completed_rounds = ?, + phase_start_time = ?, + status = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + new_phase, + current_round, + completed_rounds, + phase_start_time, + new_status, + session_id, + ), + ) + conn.commit() + + logger.debug( + f"Switched session {session_id} to phase '{new_phase}', " + f"round {current_round}/{total_rounds}, " + f"completed {completed_rounds}" + ) + + # Return updated session + return await self.get_by_id(session_id) or {} + + except Exception as e: + logger.error( + f"Failed to switch phase for session {session_id}: {e}", + exc_info=True, + ) + raise + + async def update_llm_evaluation( + self, + session_id: str, + evaluation_result: Dict[str, Any] + ) -> None: + """ + Save LLM evaluation result to database + + Args: + session_id: Session ID + evaluation_result: Complete LLM evaluation dict (will be JSON-serialized) + """ + try: + from datetime import datetime + + with self._get_conn() as conn: + conn.execute( + """ + UPDATE pomodoro_sessions + SET llm_evaluation_result = ?, + llm_evaluation_computed_at = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + json.dumps(evaluation_result), + datetime.now().isoformat(), + session_id, + ), + ) + conn.commit() + logger.debug(f"Saved LLM evaluation for session {session_id}") + except Exception as e: + logger.error( + f"Failed to save LLM evaluation for session {session_id}: {e}", + exc_info=True, + ) + raise + + async def get_llm_evaluation(self, session_id: str) -> Optional[Dict[str, Any]]: + """ + Retrieve cached LLM evaluation result + + Args: + session_id: Session ID + + Returns: + LLM evaluation dict or None if not cached + """ + try: + session = await self.get_by_id(session_id) + if not session or not session.get("llm_evaluation_result"): + return None + + return json.loads(session["llm_evaluation_result"]) + except Exception as e: + logger.warning( + f"Failed to retrieve cached LLM evaluation for {session_id}: {e}" + ) + return None diff --git a/backend/core/db/pomodoro_work_phases.py b/backend/core/db/pomodoro_work_phases.py new file mode 100644 index 0000000..513b1e0 --- /dev/null +++ b/backend/core/db/pomodoro_work_phases.py @@ -0,0 +1,203 @@ +""" +Repository for Pomodoro work phases. + +This repository handles phase-level tracking for Pomodoro sessions, +enabling independent status management and retry mechanisms for each work phase. +""" + +from pathlib import Path +from typing import Optional, List, Dict, Any +from uuid import uuid4 + +from core.logger import get_logger +from core.sqls.queries import ( + INSERT_WORK_PHASE, + SELECT_WORK_PHASES_BY_SESSION, + SELECT_WORK_PHASE_BY_SESSION_AND_NUMBER, + UPDATE_WORK_PHASE_STATUS, + UPDATE_WORK_PHASE_COMPLETED, + INCREMENT_WORK_PHASE_RETRY, +) + +from .base import BaseRepository + +logger = get_logger(__name__) + + +class PomodoroWorkPhasesRepository(BaseRepository): + """Repository for managing Pomodoro work phase records.""" + + def __init__(self, db_path: Path): + super().__init__(db_path) + + async def create( + self, + session_id: str, + phase_number: int, + phase_start_time: str, + phase_end_time: Optional[str] = None, + status: str = "pending", + retry_count: int = 0, + ) -> str: + """ + Create a work phase record. + + Args: + session_id: Pomodoro session ID + phase_number: Phase number (1-4) + phase_start_time: ISO format start time + phase_end_time: ISO format end time (optional) + status: Initial status (default: pending) + retry_count: Initial retry count (default: 0) + + Returns: + phase_id: Created phase record ID + """ + phase_id = str(uuid4()) + + try: + with self._get_conn() as conn: + conn.execute( + INSERT_WORK_PHASE, + ( + phase_id, + session_id, + phase_number, + status, + phase_start_time, + phase_end_time, + retry_count, + ), + ) + conn.commit() + + logger.info( + f"Created work phase: id={phase_id}, session={session_id}, " + f"phase={phase_number}, status={status}" + ) + + return phase_id + except Exception as e: + logger.error(f"Failed to create work phase: {e}", exc_info=True) + raise + + async def get_by_session(self, session_id: str) -> List[Dict[str, Any]]: + """ + Get all work phase records for a session. + + Args: + session_id: Pomodoro session ID + + Returns: + List of phase records (sorted by phase_number ASC) + """ + try: + with self._get_conn() as conn: + cursor = conn.execute(SELECT_WORK_PHASES_BY_SESSION, (session_id,)) + rows = cursor.fetchall() + return [dict(row) for row in rows] + except Exception as e: + logger.error(f"Failed to get phases for session {session_id}: {e}", exc_info=True) + return [] + + async def get_by_session_and_phase( + self, session_id: str, phase_number: int + ) -> Optional[Dict[str, Any]]: + """ + Get a specific work phase record. + + Args: + session_id: Pomodoro session ID + phase_number: Phase number (1-4) + + Returns: + Phase record dict or None if not found + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + SELECT_WORK_PHASE_BY_SESSION_AND_NUMBER, (session_id, phase_number) + ) + row = cursor.fetchone() + return dict(row) if row else None + except Exception as e: + logger.error( + f"Failed to get phase for session {session_id}, phase {phase_number}: {e}", + exc_info=True + ) + return None + + async def update_status( + self, + phase_id: str, + status: str, + processing_error: Optional[str] = None, + retry_count: Optional[int] = None, + ) -> None: + """ + Update phase status and error information. + + Args: + phase_id: Phase record ID + status: New status (pending/processing/completed/failed) + processing_error: Error message (optional) + retry_count: Retry count (optional) + """ + try: + with self._get_conn() as conn: + conn.execute( + UPDATE_WORK_PHASE_STATUS, + (status, processing_error, retry_count, phase_id), + ) + conn.commit() + + logger.debug( + f"Updated phase status: id={phase_id}, status={status}, " + f"error={processing_error}, retry_count={retry_count}" + ) + except Exception as e: + logger.error(f"Failed to update phase status: {e}", exc_info=True) + raise + + async def mark_completed(self, phase_id: str, activity_count: int) -> None: + """ + Mark phase as completed with activity count. + + Args: + phase_id: Phase record ID + activity_count: Number of activities created for this phase + """ + try: + with self._get_conn() as conn: + conn.execute(UPDATE_WORK_PHASE_COMPLETED, (activity_count, phase_id)) + conn.commit() + + logger.info( + f"Marked phase completed: id={phase_id}, activity_count={activity_count}" + ) + except Exception as e: + logger.error(f"Failed to mark phase completed: {e}", exc_info=True) + raise + + async def increment_retry_count(self, phase_id: str) -> int: + """ + Increment retry count and return new value. + + Args: + phase_id: Phase record ID + + Returns: + New retry count value + """ + try: + with self._get_conn() as conn: + cursor = conn.execute(INCREMENT_WORK_PHASE_RETRY, (phase_id,)) + row = cursor.fetchone() + conn.commit() + + new_count = row[0] if row else 0 + logger.debug(f"Incremented retry count for phase {phase_id}: {new_count}") + return new_count + except Exception as e: + logger.error(f"Failed to increment retry count: {e}", exc_info=True) + return 0 diff --git a/backend/core/db/raw_records.py b/backend/core/db/raw_records.py new file mode 100644 index 0000000..a2086ec --- /dev/null +++ b/backend/core/db/raw_records.py @@ -0,0 +1,227 @@ +""" +RawRecords Repository - Handles raw record persistence for Pomodoro sessions +Raw records are temporary storage for screenshots, keyboard, and mouse activity +""" + +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional + +from core.logger import get_logger + +from .base import BaseRepository + +logger = get_logger(__name__) + + +class RawRecordsRepository(BaseRepository): + """Repository for managing raw records in the database""" + + def __init__(self, db_path: Path): + super().__init__(db_path) + + async def save( + self, + timestamp: str, + record_type: str, + data: str, + pomodoro_session_id: Optional[str] = None, + ) -> Optional[int]: + """ + Save a raw record to database + + Args: + timestamp: ISO format timestamp + record_type: Type of record (SCREENSHOT_RECORD, KEYBOARD_RECORD, MOUSE_RECORD) + data: JSON string of record data + pomodoro_session_id: Optional Pomodoro session ID + + Returns: + Record ID if successful, None otherwise + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + INSERT INTO raw_records ( + timestamp, type, data, pomodoro_session_id, created_at + ) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + """, + (timestamp, record_type, data, pomodoro_session_id), + ) + conn.commit() + record_id = cursor.lastrowid + logger.debug( + f"Saved raw record: {record_id}, " + f"type={record_type}, pomodoro_session={pomodoro_session_id}" + ) + return record_id + except Exception as e: + logger.error(f"Failed to save raw record: {e}", exc_info=True) + raise + + async def get_by_session( + self, + session_id: str, + limit: int = 100, + offset: int = 0, + ) -> List[Dict[str, Any]]: + """ + Get raw records for a specific Pomodoro session + + Args: + session_id: Pomodoro session ID + limit: Maximum number of records to return + offset: Number of records to skip + + Returns: + List of raw record dictionaries + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT * FROM raw_records + WHERE pomodoro_session_id = ? + ORDER BY timestamp ASC + LIMIT ? OFFSET ? + """, + (session_id, limit, offset), + ) + rows = cursor.fetchall() + return self._rows_to_dicts(rows) + except Exception as e: + logger.error( + f"Failed to get raw records for session {session_id}: {e}", + exc_info=True, + ) + raise + + async def count_by_session(self, session_id: str) -> int: + """ + Count raw records for a session + + Args: + session_id: Pomodoro session ID + + Returns: + Number of raw records + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT COUNT(*) as count FROM raw_records + WHERE pomodoro_session_id = ? + """, + (session_id,), + ) + row = cursor.fetchone() + return row["count"] if row else 0 + except Exception as e: + logger.error( + f"Failed to count raw records for session {session_id}: {e}", + exc_info=True, + ) + raise + + async def delete_by_session(self, session_id: str) -> int: + """ + Delete raw records for a session + + Args: + session_id: Pomodoro session ID + + Returns: + Number of records deleted + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + DELETE FROM raw_records + WHERE pomodoro_session_id = ? + """, + (session_id,), + ) + conn.commit() + deleted_count = cursor.rowcount + logger.debug( + f"Deleted {deleted_count} raw records for session {session_id}" + ) + return deleted_count + except Exception as e: + logger.error( + f"Failed to delete raw records for session {session_id}: {e}", + exc_info=True, + ) + raise + + async def get_by_time_range( + self, + start_time: str, + end_time: str, + record_type: Optional[str] = None, + session_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """ + Get raw records within a time range + + Args: + start_time: Start timestamp (ISO format) + end_time: End timestamp (ISO format) + record_type: Optional filter by record type + session_id: Optional filter by Pomodoro session ID + + Returns: + List of raw record dictionaries + """ + try: + with self._get_conn() as conn: + # Build query based on filters + if record_type and session_id: + cursor = conn.execute( + """ + SELECT * FROM raw_records + WHERE timestamp >= ? AND timestamp <= ? + AND type = ? AND pomodoro_session_id = ? + ORDER BY timestamp ASC + """, + (start_time, end_time, record_type, session_id), + ) + elif record_type: + cursor = conn.execute( + """ + SELECT * FROM raw_records + WHERE timestamp >= ? AND timestamp <= ? AND type = ? + ORDER BY timestamp ASC + """, + (start_time, end_time, record_type), + ) + elif session_id: + cursor = conn.execute( + """ + SELECT * FROM raw_records + WHERE timestamp >= ? AND timestamp <= ? + AND pomodoro_session_id = ? + ORDER BY timestamp ASC + """, + (start_time, end_time, session_id), + ) + else: + cursor = conn.execute( + """ + SELECT * FROM raw_records + WHERE timestamp >= ? AND timestamp <= ? + ORDER BY timestamp ASC + """, + (start_time, end_time), + ) + rows = cursor.fetchall() + return self._rows_to_dicts(rows) + except Exception as e: + logger.error( + f"Failed to get raw records by time range: {e}", exc_info=True + ) + raise diff --git a/backend/core/db/todos.py b/backend/core/db/todos.py index bc3c8d8..a51be16 100644 --- a/backend/core/db/todos.py +++ b/backend/core/db/todos.py @@ -3,7 +3,7 @@ """ import json -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import Any, Dict, List, Optional @@ -13,6 +13,9 @@ logger = get_logger(__name__) +# Default expiration for AI-generated todos (3 days) +DEFAULT_TODO_EXPIRATION_DAYS = 3 + class TodosRepository(BaseRepository): """Repository for managing todos in the database""" @@ -33,18 +36,43 @@ async def save( scheduled_end_time: Optional[str] = None, recurrence_rule: Optional[Dict[str, Any]] = None, created_at: Optional[str] = None, + expires_at: Optional[str] = None, + source_type: str = "ai", ) -> None: - """Save or update a todo""" + """Save or update a todo + + Args: + todo_id: Unique todo identifier + title: Todo title + description: Todo description + keywords: List of keywords/tags + completed: Whether todo is completed + scheduled_date: Optional scheduled date (YYYY-MM-DD) + scheduled_time: Optional scheduled time (HH:MM) + scheduled_end_time: Optional scheduled end time (HH:MM) + recurrence_rule: Optional recurrence configuration + created_at: Custom creation timestamp (ISO format) + expires_at: Custom expiration timestamp (ISO format), auto-calculated for AI todos if not provided + source_type: 'ai' or 'manual' - defaults to 'ai' + """ try: created = created_at or datetime.now().isoformat() + + # Calculate expiration for AI-generated todos if not provided + calculated_expires_at = expires_at + if source_type == "ai" and expires_at is None: + expiration_time = datetime.fromisoformat(created) + timedelta(days=DEFAULT_TODO_EXPIRATION_DAYS) + calculated_expires_at = expiration_time.isoformat() + with self._get_conn() as conn: conn.execute( """ INSERT OR REPLACE INTO todos ( id, title, description, keywords, created_at, completed, deleted, - scheduled_date, scheduled_time, scheduled_end_time, recurrence_rule - ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?) + scheduled_date, scheduled_time, scheduled_end_time, recurrence_rule, + expires_at, source_type + ) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?) """, ( todo_id, @@ -57,10 +85,12 @@ async def save( scheduled_time, scheduled_end_time, json.dumps(recurrence_rule) if recurrence_rule else None, + calculated_expires_at, + source_type, ), ) conn.commit() - logger.debug(f"Saved todo: {todo_id}") + logger.debug(f"Saved todo: {todo_id} (source: {source_type}, expires: {calculated_expires_at})") # Send event to frontend from core.events import emit_todo_created @@ -77,6 +107,8 @@ async def save( "scheduled_end_time": scheduled_end_time, "recurrence_rule": recurrence_rule, "created_at": created, + "expires_at": calculated_expires_at, + "source_type": source_type, "type": "original", } ) @@ -85,40 +117,119 @@ async def save( raise + async def get_by_id(self, todo_id: str) -> Optional[Dict[str, Any]]: + """ + Get a single todo by ID + + Args: + todo_id: Todo ID + + Returns: + Todo dict or None if not found + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + """ + SELECT id, title, description, keywords, + created_at, completed, deleted, scheduled_date, scheduled_time, + scheduled_end_time, recurrence_rule, expires_at, source_type + FROM todos + WHERE id = ? + """, + (todo_id,), + ) + row = cursor.fetchone() + + if row: + return { + "id": row["id"], + "title": row["title"], + "description": row["description"], + "keywords": json.loads(row["keywords"]) + if row["keywords"] + else [], + "created_at": row["created_at"], + "completed": bool(row["completed"]), + "deleted": bool(row["deleted"]), + "scheduled_date": row["scheduled_date"], + "scheduled_time": row["scheduled_time"], + "scheduled_end_time": row["scheduled_end_time"], + "recurrence_rule": json.loads(row["recurrence_rule"]) + if row["recurrence_rule"] + else None, + "expires_at": row["expires_at"], + "source_type": row["source_type"] or "ai", + } + + return None + + except Exception as e: + logger.error(f"Failed to get todo by ID {todo_id}: {e}", exc_info=True) + return None + async def get_list( - self, include_completed: bool = False + self, include_completed: bool = False, include_expired: bool = False ) -> List[Dict[str, Any]]: """ Get todo list (from todos table) Args: include_completed: Whether to include completed todos + include_expired: Whether to include expired AI todos (default False) Returns: List of todo dictionaries """ try: + now_iso = datetime.now().isoformat() + if include_completed: - query = """ - SELECT id, title, description, keywords, - created_at, completed, deleted, scheduled_date, scheduled_time, - scheduled_end_time, recurrence_rule - FROM todos - WHERE deleted = 0 - ORDER BY completed ASC, created_at DESC - """ + if include_expired: + query = """ + SELECT id, title, description, keywords, + created_at, completed, deleted, scheduled_date, scheduled_time, + scheduled_end_time, recurrence_rule, expires_at, source_type + FROM todos + WHERE deleted = 0 + ORDER BY completed ASC, created_at DESC + """ + else: + query = """ + SELECT id, title, description, keywords, + created_at, completed, deleted, scheduled_date, scheduled_time, + scheduled_end_time, recurrence_rule, expires_at, source_type + FROM todos + WHERE deleted = 0 + AND (source_type = 'manual' OR expires_at IS NULL OR expires_at > ?) + ORDER BY completed ASC, created_at DESC + """ else: - query = """ - SELECT id, title, description, keywords, - created_at, completed, deleted, scheduled_date, scheduled_time, - scheduled_end_time, recurrence_rule - FROM todos - WHERE deleted = 0 AND completed = 0 - ORDER BY created_at DESC - """ + if include_expired: + query = """ + SELECT id, title, description, keywords, + created_at, completed, deleted, scheduled_date, scheduled_time, + scheduled_end_time, recurrence_rule, expires_at, source_type + FROM todos + WHERE deleted = 0 AND completed = 0 + ORDER BY created_at DESC + """ + else: + query = """ + SELECT id, title, description, keywords, + created_at, completed, deleted, scheduled_date, scheduled_time, + scheduled_end_time, recurrence_rule, expires_at, source_type + FROM todos + WHERE deleted = 0 AND completed = 0 + AND (source_type = 'manual' OR expires_at IS NULL OR expires_at > ?) + ORDER BY created_at DESC + """ with self._get_conn() as conn: - cursor = conn.execute(query) + if include_completed and include_expired: + cursor = conn.execute(query) + else: + cursor = conn.execute(query, (now_iso,)) rows = cursor.fetchall() todo_list: List[Dict[str, Any]] = [] @@ -140,6 +251,8 @@ async def get_list( "recurrence_rule": json.loads(row["recurrence_rule"]) if row["recurrence_rule"] else None, + "expires_at": row["expires_at"], + "source_type": row["source_type"] or "ai", } ) @@ -160,6 +273,9 @@ async def schedule( """ Schedule todo to a specific date and optional time window + Scheduling a todo clears its expiration time, as the todo is now in + an active/scheduled state and should not expire. + Args: todo_id: Todo ID scheduled_date: Scheduled date in YYYY-MM-DD format @@ -176,11 +292,12 @@ async def schedule( recurrence_json = json.dumps(recurrence_rule) if recurrence_rule else None + # Clear expires_at when scheduling (todo is now active) cursor.execute( """ UPDATE todos SET scheduled_date = ?, scheduled_time = ?, - scheduled_end_time = ?, recurrence_rule = ? + scheduled_end_time = ?, recurrence_rule = ?, expires_at = NULL WHERE id = ? AND deleted = 0 """, ( @@ -197,7 +314,7 @@ async def schedule( """ SELECT id, title, description, keywords, created_at, completed, deleted, scheduled_date, scheduled_time, - scheduled_end_time, recurrence_rule + scheduled_end_time, recurrence_rule, expires_at, source_type FROM todos WHERE id = ? AND deleted = 0 """, @@ -222,6 +339,8 @@ async def schedule( "recurrence_rule": json.loads(row["recurrence_rule"]) if row["recurrence_rule"] else None, + "expires_at": row["expires_at"], + "source_type": row["source_type"] or "ai", } # Send event to frontend @@ -238,7 +357,12 @@ async def schedule( return None async def unschedule(self, todo_id: str) -> Optional[Dict[str, Any]]: - """Clear scheduling info for a todo""" + """Clear scheduling info for a todo + + Note: When unscheduling, we do NOT restore the expiration time. + The todo remains without expiration since the user has explicitly + interacted with it (scheduled then unscheduled). + """ try: with self._get_conn() as conn: cursor = conn.cursor() @@ -259,7 +383,8 @@ async def unschedule(self, todo_id: str) -> Optional[Dict[str, Any]]: cursor.execute( """ SELECT id, title, description, keywords, - created_at, completed, deleted, scheduled_date + created_at, completed, deleted, scheduled_date, + expires_at, source_type FROM todos WHERE id = ? AND deleted = 0 """, @@ -279,6 +404,8 @@ async def unschedule(self, todo_id: str) -> Optional[Dict[str, Any]]: "completed": bool(row["completed"]), "deleted": bool(row["deleted"]), "scheduled_date": row["scheduled_date"], + "expires_at": row["expires_at"], + "source_type": row["source_type"] or "ai", } # Send event to frontend @@ -294,6 +421,24 @@ async def unschedule(self, todo_id: str) -> Optional[Dict[str, Any]]: logger.error(f"Failed to unschedule todo: {e}", exc_info=True) return None + async def complete(self, todo_id: str) -> None: + """Mark a todo as completed""" + try: + with self._get_conn() as conn: + conn.execute( + "UPDATE todos SET completed = 1 WHERE id = ?", (todo_id,) + ) + conn.commit() + logger.debug(f"Completed todo: {todo_id}") + + # Send event to frontend + from core.events import emit_todo_updated + + emit_todo_updated({"id": todo_id, "completed": True}) + except Exception as e: + logger.error(f"Failed to complete todo {todo_id}: {e}", exc_info=True) + raise + async def delete(self, todo_id: str) -> None: """Soft delete a todo""" try: @@ -360,3 +505,71 @@ async def delete_by_date_range(self, start_iso: str, end_iso: str) -> int: exc_info=True, ) return 0 + + async def delete_expired(self) -> int: + """ + Soft delete expired AI-generated todos + + Removes todos that: + - Are AI-generated (source_type = 'ai') + - Have an expires_at timestamp in the past + - Are not already deleted + - Are not completed + + Returns: + Number of todos soft-deleted + """ + try: + now_iso = datetime.now().isoformat() + + with self._get_conn() as conn: + cursor = conn.execute( + """ + UPDATE todos + SET deleted = 1 + WHERE deleted = 0 + AND completed = 0 + AND source_type = 'ai' + AND expires_at IS NOT NULL + AND expires_at < ? + """, + (now_iso,), + ) + deleted_count = cursor.rowcount + conn.commit() + + if deleted_count > 0: + logger.info(f"Soft-deleted {deleted_count} expired AI todos") + + return deleted_count + + except Exception as e: + logger.error(f"Failed to delete expired todos: {e}", exc_info=True) + return 0 + + async def delete_soft_deleted_permanent(self) -> int: + """ + Permanently delete all soft-deleted todos + + This is a cleanup operation that removes todos that have been + soft-deleted (deleted = 1) from the database. + + Returns: + Number of todos permanently deleted + """ + try: + with self._get_conn() as conn: + cursor = conn.execute( + "DELETE FROM todos WHERE deleted = 1" + ) + deleted_count = cursor.rowcount + conn.commit() + + if deleted_count > 0: + logger.info(f"Permanently deleted {deleted_count} soft-deleted todos") + + return deleted_count + + except Exception as e: + logger.error(f"Failed to permanently delete soft-deleted todos: {e}", exc_info=True) + return 0 diff --git a/backend/core/events.py b/backend/core/events.py index e60d194..24837c0 100644 --- a/backend/core/events.py +++ b/backend/core/events.py @@ -207,6 +207,7 @@ def emit_monitors_changed( ) -> bool: """ Send \"monitors changed\" event to frontend when connected displays change. + Also notifies the perception manager to update monitor bounds. """ from datetime import datetime @@ -219,6 +220,17 @@ def emit_monitors_changed( success = _emit("monitors-changed", payload) if success: logger.debug("✅ Monitors changed event sent") + + # Notify perception manager to update monitor tracker bounds + try: + from core.coordinator import get_coordinator + coordinator = get_coordinator() + if coordinator and coordinator.perception_manager: + coordinator.perception_manager.handle_monitors_changed() + logger.debug("✓ Perception manager notified of monitor changes") + except Exception as e: + logger.error(f"Failed to notify perception manager of monitor changes: {e}") + return success @@ -267,6 +279,7 @@ def emit_chat_message_chunk( done: bool = False, message_id: Optional[str] = None, timestamp: Optional[str] = None, + error: bool = False, ) -> bool: """ Send "chat message chunk" event to frontend (for streaming output) @@ -275,7 +288,9 @@ def emit_chat_message_chunk( conversation_id: Conversation ID chunk: Text chunk content done: Whether completed (True indicates streaming output ended) - message_id: Message ID (optional, provided when completed) + message_id: Message ID (optional, provided when completed successfully) + timestamp: Optional timestamp + error: Whether this is an error response (True indicates stream failed) Returns: True if sent successfully, False otherwise @@ -284,6 +299,7 @@ def emit_chat_message_chunk( "conversationId": conversation_id, "chunk": chunk, "done": done, + "error": error, } if message_id is not None: @@ -291,7 +307,10 @@ def emit_chat_message_chunk( success = _emit("chat-message-chunk", payload) if success and done: - logger.debug(f"✅ Chat message completion event sent: {conversation_id}") + if error: + logger.debug(f"❌ Chat message error event sent: {conversation_id}") + else: + logger.debug(f"✅ Chat message completion event sent: {conversation_id}") return success @@ -544,3 +563,219 @@ def emit_todo_deleted(todo_id: str, timestamp: Optional[str] = None) -> bool: if success: logger.debug(f"✅ TODO deletion event sent: {todo_id}") return success + + + +def emit_pomodoro_processing_progress( + session_id: str, job_id: str, processed: int +) -> bool: + """ + Send Pomodoro processing progress event to frontend + + Args: + session_id: Pomodoro session ID + job_id: Processing job ID + processed: Number of records processed + + Returns: + True if sent successfully, False otherwise + """ + payload = { + "session_id": session_id, + "job_id": job_id, + "processed": processed, + } + + logger.debug( + f"[emit_pomodoro_processing_progress] Session: {session_id}, " + f"Job: {job_id}, Processed: {processed}" + ) + return _emit("pomodoro-processing-progress", payload) + + +def emit_pomodoro_processing_complete( + session_id: str, job_id: str, total_processed: int +) -> bool: + """ + Send Pomodoro processing completion event to frontend + + Args: + session_id: Pomodoro session ID + job_id: Processing job ID + total_processed: Total number of records processed + + Returns: + True if sent successfully, False otherwise + """ + payload = { + "session_id": session_id, + "job_id": job_id, + "total_processed": total_processed, + } + + logger.debug( + f"[emit_pomodoro_processing_complete] Session: {session_id}, " + f"Job: {job_id}, Total: {total_processed}" + ) + return _emit("pomodoro-processing-complete", payload) + + +def emit_pomodoro_processing_failed( + session_id: str, job_id: str, error: str +) -> bool: + """ + Send Pomodoro processing failure event to frontend + + Args: + session_id: Pomodoro session ID + job_id: Processing job ID + error: Error message + + Returns: + True if sent successfully, False otherwise + """ + payload = { + "session_id": session_id, + "job_id": job_id, + "error": error, + } + + logger.debug( + f"[emit_pomodoro_processing_failed] Session: {session_id}, " + f"Job: {job_id}, Error: {error}" + ) + return _emit("pomodoro-processing-failed", payload) + + +def emit_pomodoro_phase_switched( + session_id: str, + new_phase: str, + current_round: int, + total_rounds: int, + completed_rounds: int, +) -> bool: + """ + Send Pomodoro phase switch event to frontend + + Emitted when session automatically switches between work/break phases. + + Args: + session_id: Pomodoro session ID + new_phase: New phase ('work', 'break', or 'completed') + current_round: Current round number (1-based) + total_rounds: Total number of rounds + completed_rounds: Number of completed work rounds + + Returns: + True if sent successfully, False otherwise + """ + payload = { + "session_id": session_id, + "new_phase": new_phase, + "current_round": current_round, + "total_rounds": total_rounds, + "completed_rounds": completed_rounds, + } + + logger.debug( + f"[emit_pomodoro_phase_switched] Session: {session_id}, " + f"Phase: {new_phase}, Round: {current_round}/{total_rounds}, " + f"Completed: {completed_rounds}" + ) + return _emit("pomodoro-phase-switched", payload) + + +def emit_pomodoro_work_phase_completed( + session_id: str, + work_phase: int, + activity_count: int, +) -> bool: + """ + Send Pomodoro work phase completed event to frontend + + Emitted when a work phase completes and activities have been generated. + Allows frontend to display notifications and refresh session detail views. + + Args: + session_id: Pomodoro session ID + work_phase: Work phase number (1-based) + activity_count: Number of activities created/updated for this work phase + + Returns: + True if sent successfully, False otherwise + """ + payload = { + "session_id": session_id, + "work_phase": work_phase, + "activity_count": activity_count, + } + + logger.debug( + f"[emit_pomodoro_work_phase_completed] Session: {session_id}, " + f"Phase: {work_phase}, Activities: {activity_count}" + ) + return _emit("pomodoro-work-phase-completed", payload) + + +def emit_pomodoro_work_phase_failed( + session_id: str, + work_phase: int, + error: str, +) -> bool: + """ + Send Pomodoro work phase failed event to frontend + + Emitted when a work phase aggregation fails after all retries exhausted. + Frontend should display error state and retry button. + + Args: + session_id: Pomodoro session ID + work_phase: Work phase number (1-based) + error: Error message describing the failure + + Returns: + True if sent successfully, False otherwise + """ + payload = { + "session_id": session_id, + "work_phase": work_phase, + "error": error, + } + + logger.debug( + f"[emit_pomodoro_work_phase_failed] Session: {session_id}, " + f"Phase: {work_phase}, Error: {error}" + ) + return _emit("pomodoro-work-phase-failed", payload) + + +def emit_pomodoro_session_deleted( + session_id: str, + timestamp: Optional[str] = None, +) -> bool: + """ + Send Pomodoro session deleted event to frontend + + Emitted when a session is deleted. Frontend should refresh session list + and clear any selected session state. + + Args: + session_id: Pomodoro session ID + timestamp: Deletion timestamp + + Returns: + True if sent successfully, False otherwise + """ + from datetime import datetime + + resolved_timestamp = timestamp or datetime.now().isoformat() + payload = { + "type": "session_deleted", + "data": {"id": session_id, "deletedAt": resolved_timestamp}, + "timestamp": resolved_timestamp, + } + + success = _emit("session-deleted", payload) + if success: + logger.debug(f"✅ Pomodoro session deletion event sent: {session_id}") + return success diff --git a/backend/core/pomodoro_manager.py b/backend/core/pomodoro_manager.py new file mode 100644 index 0000000..1d9de6a --- /dev/null +++ b/backend/core/pomodoro_manager.py @@ -0,0 +1,1831 @@ +""" +Pomodoro Manager - Manages Pomodoro session lifecycle + +Responsibilities: +1. Start/stop Pomodoro sessions +2. Coordinate with PipelineCoordinator (enter/exit Pomodoro mode) +3. Trigger deferred batch processing after session completion +4. Track session metadata and handle orphaned sessions +""" + +import asyncio +import uuid +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple + +from core.db import get_db +from core.events import ( + emit_pomodoro_phase_switched, + emit_pomodoro_processing_complete, + emit_pomodoro_processing_failed, + emit_pomodoro_processing_progress, + emit_pomodoro_work_phase_completed, + emit_pomodoro_work_phase_failed, +) +from core.logger import get_logger + +if TYPE_CHECKING: + from llm.focus_evaluator import FocusEvaluator + +logger = get_logger(__name__) + + +class _Constants: + """Pomodoro manager constants to eliminate magic numbers""" + + # Session timing + PROCESSING_STUCK_THRESHOLD_MINUTES = 15 + MIN_SESSION_DURATION_MINUTES = 2 + + # Processing timeouts + MAX_PHASE_WAIT_SECONDS = 300 # 5 minutes + TOTAL_PROCESSING_TIMEOUT_SECONDS = 600 # 10 minutes + POLL_INTERVAL_SECONDS = 3 + + # Retry configuration + MAX_RETRIES = 1 + RETRY_DELAY_SECONDS = 10 + + # Default Pomodoro settings + DEFAULT_WORK_DURATION_MINUTES = 25 + DEFAULT_BREAK_DURATION_MINUTES = 5 + DEFAULT_TOTAL_ROUNDS = 4 + + +class PomodoroSession: + """Pomodoro session data class""" + + def __init__( + self, + session_id: str, + user_intent: str, + duration_minutes: int, + start_time: datetime, + ): + self.id = session_id + self.user_intent = user_intent + self.duration_minutes = duration_minutes + self.start_time = start_time + + +class PomodoroManager: + """ + Pomodoro session manager + + Handles Pomodoro lifecycle and coordinates with coordinator + """ + + def __init__(self, coordinator): + """ + Initialize Pomodoro manager + + Args: + coordinator: Reference to PipelineCoordinator instance + """ + self.coordinator = coordinator + self.db = get_db() + self.current_session: PomodoroSession | None = None + self.is_active = False + self._processing_tasks: dict[str, asyncio.Task] = {} + + # ============================================================ + # Helper Methods - Session State Management + # ============================================================ + + def _clear_session_state(self) -> None: + """Clear current session state (unified cleanup)""" + self.is_active = False + self.current_session = None + + def _cancel_phase_timer(self, session_id: str) -> None: + """Cancel phase timer for a session if running""" + if session_id in self._processing_tasks: + self._processing_tasks[session_id].cancel() + del self._processing_tasks[session_id] + logger.debug(f"Cancelled phase timer for session {session_id}") + + def _get_session_defaults(self, session: dict[str, Any]) -> tuple[int, int, int]: + """ + Get session configuration with defaults + + Returns: + Tuple of (work_duration, break_duration, total_rounds) + """ + return ( + session.get("work_duration_minutes", _Constants.DEFAULT_WORK_DURATION_MINUTES), + session.get("break_duration_minutes", _Constants.DEFAULT_BREAK_DURATION_MINUTES), + session.get("total_rounds", _Constants.DEFAULT_TOTAL_ROUNDS), + ) + + # ============================================================ + # Helper Methods - Time Calculations + # ============================================================ + + def _calculate_elapsed_minutes(self, end_time: datetime) -> float: + """Calculate elapsed minutes from session start""" + if not self.current_session: + return 0.0 + return (end_time - self.current_session.start_time).total_seconds() / 60 + + async def _calculate_actual_work_minutes( + self, + session: dict[str, Any], + end_time: datetime, + ) -> int: + """ + Calculate actual work duration in minutes + + For completed rounds: use full work_duration + For current incomplete work phase: use actual elapsed time + + Args: + session: Session record from database + end_time: Session end time + + Returns: + Actual work minutes (integer) + """ + completed_rounds = session.get("completed_rounds", 0) + work_duration, _, _ = self._get_session_defaults(session) + current_phase = session.get("current_phase", "work") + + # Calculate time for completed rounds + actual_work_minutes = completed_rounds * work_duration + + # If ending during work phase, add actual time worked in current phase + if current_phase == "work": + phase_start_time_str = session.get("phase_start_time") + if phase_start_time_str: + phase_start_time = datetime.fromisoformat(phase_start_time_str) + current_phase_minutes = (end_time - phase_start_time).total_seconds() / 60 + actual_work_minutes += int(current_phase_minutes) + logger.debug( + f"Adding {int(current_phase_minutes)}min from current work phase" + ) + else: + # Fallback: use full work_duration for current phase + actual_work_minutes += work_duration + logger.warning("No phase_start_time found, using full work_duration") + + return actual_work_minutes + + # ============================================================ + # Helper Methods - Event Emission + # ============================================================ + + def _emit_phase_completion_event( + self, + session_id: str, + session: dict[str, Any] | None = None, + phase: str = "completed", + ) -> None: + """ + Emit phase switched event (unified helper) + + Args: + session_id: Session ID + session: Optional session dict for round info + phase: Phase name (default: "completed") + """ + current_round = 1 + total_rounds = _Constants.DEFAULT_TOTAL_ROUNDS + completed_rounds = 0 + + if session: + current_round = session.get("current_round", 1) + total_rounds = session.get("total_rounds", _Constants.DEFAULT_TOTAL_ROUNDS) + completed_rounds = session.get("completed_rounds", 0) + + emit_pomodoro_phase_switched( + session_id=session_id, + new_phase=phase, + current_round=current_round, + total_rounds=total_rounds, + completed_rounds=completed_rounds, + ) + logger.debug(f"Emitted phase event: session={session_id}, phase={phase}") + + def _emit_progress_event( + self, session_id: str, job_id: str, processed: int + ) -> None: + """Emit progress event for frontend""" + try: + emit_pomodoro_processing_progress(session_id, job_id, processed) + except Exception as e: + logger.debug(f"Failed to emit progress event: {e}") + + def _emit_completion_event( + self, session_id: str, job_id: str, total_processed: int + ) -> None: + """Emit completion event for frontend""" + try: + emit_pomodoro_processing_complete(session_id, job_id, total_processed) + except Exception as e: + logger.debug(f"Failed to emit completion event: {e}") + + def _emit_failure_event( + self, session_id: str, job_id: str, error: str + ) -> None: + """Emit failure event for frontend""" + try: + emit_pomodoro_processing_failed(session_id, job_id, error) + except Exception as e: + logger.debug(f"Failed to emit failure event: {e}") + + # ============================================================ + # Helper Methods - Processing Status Checks + # ============================================================ + + async def _check_and_handle_stuck_processing(self) -> None: + """ + Check for orphaned ACTIVE sessions only (not processing status) + + Processing status is now independent and should NOT block new sessions. + Only check for truly orphaned active sessions from app crashes. + + Background processing runs independently and doesn't prevent new sessions. + """ + # ✅ NEW: Only check status="active" (orphaned sessions from crashes) + # Processing status is independent and doesn't block new sessions + active_sessions = await self.db.pomodoro_sessions.get_by_status("active") + if not active_sessions: + return + + # Orphaned active session found - clean it up + for session in active_sessions: + session_id = session["id"] + logger.warning( + f"Found orphaned active session {session_id}, " + f"marking as abandoned (app restart detected)" + ) + + # Force end the orphaned session + # This code path only triggers on app restart after crash + await self.db.pomodoro_sessions.update( + session_id=session_id, + status="abandoned", + processing_status="failed", + processing_error="Session orphaned (app restart detected)", + ) + + def _classify_aggregation_error(self, error: Exception) -> str: + """ + Classify aggregation errors for better user feedback. + + Returns: + - 'no_actions_found': No user activity during phase + - 'llm_clustering_failed': LLM API call failed + - 'supervisor_validation_failed': Activity validation failed + - 'database_save_failed': Database operation failed + - 'unknown_error': Unclassified error + """ + error_str = str(error).lower() + + if "no actions found" in error_str or "no action" in error_str: + return "no_actions_found" + elif "clustering" in error_str or "llm" in error_str or "api" in error_str: + return "llm_clustering_failed" + elif "supervisor" in error_str or "validation" in error_str: + return "supervisor_validation_failed" + elif "database" in error_str or "sql" in error_str: + return "database_save_failed" + else: + return "unknown_error" + + # ============================================================ + # Main Public Methods + # ============================================================ + + async def start_pomodoro( + self, + user_intent: str, + duration_minutes: int = 25, + associated_todo_id: Optional[str] = None, + work_duration_minutes: int = 25, + break_duration_minutes: int = 5, + total_rounds: int = 4, + ) -> str: + """ + Start a new Pomodoro session with rounds + + Actions: + 1. Create pomodoro_sessions record + 2. Signal coordinator to enter "pomodoro mode" + 3. Start phase timer for automatic work/break switching + 4. Coordinator disables continuous processing + 5. PerceptionManager captures during work phase only + + Args: + user_intent: User's description of what they plan to work on + duration_minutes: Total planned duration (calculated from rounds) + associated_todo_id: Optional TODO ID to associate with this session + work_duration_minutes: Duration of each work phase (default: 25) + break_duration_minutes: Duration of each break phase (default: 5) + total_rounds: Total number of work rounds (default: 4) + + Returns: + session_id + + Raises: + ValueError: If a Pomodoro session is already active + """ + if self.is_active: + raise ValueError("A Pomodoro session is already active") + + # Check for stuck processing sessions + await self._check_and_handle_stuck_processing() + + session_id = str(uuid.uuid4()) + start_time = datetime.now() + + # Calculate total duration: (work + break) * rounds - last break + total_duration = (work_duration_minutes + break_duration_minutes) * total_rounds - break_duration_minutes + + try: + # Save to database + await self.db.pomodoro_sessions.create( + session_id=session_id, + user_intent=user_intent, + planned_duration_minutes=total_duration, + start_time=start_time.isoformat(), + status="active", + associated_todo_id=associated_todo_id, + work_duration_minutes=work_duration_minutes, + break_duration_minutes=break_duration_minutes, + total_rounds=total_rounds, + ) + + # Create session object + self.current_session = PomodoroSession( + session_id=session_id, + user_intent=user_intent, + duration_minutes=total_duration, + start_time=start_time, + ) + self.is_active = True + + # Signal coordinator to enter pomodoro mode (work phase) + await self.coordinator.enter_pomodoro_mode(session_id) + + # Start phase timer for automatic switching + self._start_phase_timer(session_id, work_duration_minutes) + + # Emit phase switch event to notify frontend that work phase started + emit_pomodoro_phase_switched( + session_id=session_id, + new_phase="work", + current_round=1, + total_rounds=total_rounds, + completed_rounds=0, + ) + + logger.info( + f"✓ Pomodoro session started: {session_id}, " + f"intent='{user_intent}', rounds={total_rounds}, " + f"work={work_duration_minutes}min, break={break_duration_minutes}min" + ) + + return session_id + + except Exception as e: + logger.error(f"Failed to start Pomodoro session: {e}", exc_info=True) + # Cleanup on failure + self.is_active = False + self.current_session = None + raise + + def _start_phase_timer(self, session_id: str, duration_minutes: int) -> None: + """ + Start a timer for current phase + + When timer expires, automatically switch to next phase. + + Args: + session_id: Session ID + duration_minutes: Duration of current phase in minutes + """ + # Cancel any existing timer for this session + if session_id in self._processing_tasks: + self._processing_tasks[session_id].cancel() + + # Create async task for phase timer + async def phase_timer(): + try: + # Wait for phase duration + await asyncio.sleep(duration_minutes * 60) + + # Switch to next phase + await self._auto_switch_phase(session_id) + + except asyncio.CancelledError: + logger.debug(f"Phase timer cancelled for session {session_id}") + except Exception as e: + logger.error( + f"Error in phase timer for session {session_id}: {e}", + exc_info=True, + ) + + # Store task reference + task = asyncio.create_task(phase_timer()) + self._processing_tasks[session_id] = task + + async def _auto_switch_phase(self, session_id: str) -> None: + """ + Automatically switch to next phase when current phase completes + + Phase transitions: + - work → break: Stop perception, start break timer + - break → work: Start perception, start work timer + - If all rounds completed: End session + + Args: + session_id: Session ID + """ + try: + # Get current session state + session = await self.db.pomodoro_sessions.get_by_id(session_id) + if not session: + logger.warning(f"Session {session_id} not found for phase switch") + return + + current_phase = session.get("current_phase", "work") + current_round = session.get("current_round", 1) + work_duration, break_duration, total_rounds = self._get_session_defaults(session) + + logger.info( + f"Auto-switching phase for session {session_id}: " + f"current_phase={current_phase}, round={current_round}/{total_rounds}" + ) + + # Determine next phase + if current_phase == "work": + # Work phase completed, switch to break + new_phase = "break" + next_duration = break_duration + + # Calculate phase timing + phase_start_time_str = session.get("phase_start_time") + if phase_start_time_str: + phase_start_time = datetime.fromisoformat(phase_start_time_str) + else: + # Fallback to session start time if phase start time not available + phase_start_time = datetime.fromisoformat( + session.get("start_time", datetime.now().isoformat()) + ) + phase_end_time = datetime.now() + + # ★ NEW: Create phase record BEFORE triggering aggregation ★ + phase_id = await self.db.work_phases.create( + session_id=session_id, + phase_number=current_round, + phase_start_time=phase_start_time.isoformat(), + phase_end_time=phase_end_time.isoformat(), + status="pending", + ) + + logger.info( + f"Created phase record: session={session_id}, " + f"phase={current_round}, id={phase_id}" + ) + + # ★ FORCE SETTLEMENT: Process all pending records before phase ends ★ + # This ensures no actions are lost during phase transition + logger.info( + f"Force settling all pending records for work phase {current_round}" + ) + settlement_result = await self.force_settlement(session_id) + if settlement_result.get("success"): + logger.info( + f"✓ Force settlement successful: " + f"{settlement_result['records_processed']['total']} records processed, " + f"{settlement_result['events_generated']} events, " + f"{settlement_result['activities_generated']} activities" + ) + else: + logger.warning( + f"Force settlement had issues but continuing: " + f"{settlement_result.get('error', 'Unknown error')}" + ) + + # ★ CRITICAL: Stop perception AFTER force settlement ★ + # This ensures no new records are captured while we're aggregating + # Stop perception during break + await self.coordinator.exit_pomodoro_mode() + + # ★ Trigger aggregation AFTER stopping perception ★ + # This guarantees all captured records have been processed + asyncio.create_task( + self._aggregate_work_phase_activities( + session_id=session_id, + work_phase=current_round, + phase_start_time=phase_start_time, + phase_end_time=phase_end_time, + phase_id=phase_id, + ) + ) + + elif current_phase == "break": + # Break completed, switch to next work round + new_phase = "work" + next_duration = work_duration + + # Resume perception for work phase + await self.coordinator.enter_pomodoro_mode(session_id) + + else: + logger.warning(f"Unknown phase '{current_phase}' for session {session_id}") + return + + # Update session phase in database (this increments completed_rounds for work→break) + phase_start_time = datetime.now().isoformat() + updated_session = await self.db.pomodoro_sessions.switch_phase( + session_id, new_phase, phase_start_time + ) + + # Check if session completed after phase switch (all rounds done) + if updated_session.get("status") == "completed": + # All rounds completed, end session + await self._complete_session(session_id) + return + + # Start timer for next phase + self._start_phase_timer(session_id, next_duration) + + # Emit phase switch event to frontend + emit_pomodoro_phase_switched( + session_id=session_id, + new_phase=new_phase, + current_round=updated_session.get("current_round", current_round), + total_rounds=total_rounds, + completed_rounds=updated_session.get("completed_rounds", 0), + ) + + logger.info( + f"✓ Switched to {new_phase} phase for session {session_id}, " + f"duration={next_duration}min" + ) + + except Exception as e: + logger.error( + f"Failed to auto-switch phase for session {session_id}: {e}", + exc_info=True, + ) + + async def _complete_session(self, session_id: str) -> None: + """ + Complete a Pomodoro session after all rounds finished + + Args: + session_id: Session ID + """ + try: + logger.info(f"Completing Pomodoro session {session_id}: all rounds finished") + + # Mark session as completed + end_time = datetime.now() + session = await self.db.pomodoro_sessions.get_by_id(session_id) + + if session: + # Calculate actual work duration based on completed rounds + completed_rounds = session.get("completed_rounds", 0) + work_duration = session.get("work_duration_minutes", 25) + actual_work_minutes = completed_rounds * work_duration + + logger.info( + f"Session completed: {completed_rounds} rounds × {work_duration}min = {actual_work_minutes}min" + ) + + await self.db.pomodoro_sessions.update( + session_id, + status="completed", + end_time=end_time.isoformat(), + actual_duration_minutes=actual_work_minutes, + current_phase="completed", + ) + + # Emit completion event to frontend (so desktop clock can switch to normal mode) + self._emit_phase_completion_event(session_id, session, "completed") + logger.info(f"Emitted completion event for session {session_id}") + + # Cleanup + self._clear_session_state() + self._cancel_phase_timer(session_id) + + # Exit pomodoro mode + await self.coordinator.exit_pomodoro_mode() + + # Trigger batch processing + await self._trigger_batch_processing(session_id) + + logger.info(f"✓ Session {session_id} completed successfully") + + except Exception as e: + logger.error(f"Failed to complete session {session_id}: {e}", exc_info=True) + + # ============================================================ + # Helper Methods - End Pomodoro Workflow + # ============================================================ + + async def _handle_too_short_session( + self, + session_id: str, + end_time: datetime, + elapsed_minutes: float, + ) -> dict[str, Any]: + """ + Handle sessions that are too short (< 2 minutes) - immediate return + + Args: + session_id: Session ID + end_time: Session end time + elapsed_minutes: Elapsed duration in minutes + + Returns: + Response dict for too-short session + """ + logger.warning( + f"Pomodoro session {session_id} too short ({elapsed_minutes:.1f}min), marking as abandoned" + ) + + # Update database (fast) + await self.db.pomodoro_sessions.update( + session_id=session_id, + end_time=end_time.isoformat(), + actual_duration_minutes=int(elapsed_minutes), + status="abandoned", + processing_status="failed", + ) + + # Emit completion event IMMEDIATELY for frontend/clock to reset state + self._emit_phase_completion_event(session_id) + logger.info(f"Emitted completion event for abandoned session {session_id}") + + # Exit pomodoro mode and cleanup + await self.coordinator.exit_pomodoro_mode() + self._clear_session_state() + + return { + "session_id": session_id, + "status": "abandoned", + "actual_work_minutes": 0, + "raw_records_count": 0, # ✅ Added back for compatibility + "message": "Session too short, marked as abandoned", + } + + async def _process_incomplete_phases( + self, + session_id: str, + session: dict[str, Any], + end_time: datetime, + ) -> None: + """ + Process all work phases that occurred during session (parallel processing) + + CRITICAL: This is a background task that must not crash. + All errors are isolated and logged. + + Args: + session_id: Session ID + session: Session record from database + end_time: Session end time + """ + try: + current_phase = session.get("current_phase", "work") + current_round = session.get("current_round", 1) + completed_rounds = session.get("completed_rounds", 0) + + # Identify all work phases to process + work_phases_to_process = list(range(1, completed_rounds + 1)) + + # Include current work phase if session ended during work + if current_phase == "work" and current_round not in work_phases_to_process: + work_phases_to_process.append(current_round) + # Increment completed_rounds to reflect this work phase + await self.db.pomodoro_sessions.update( + session_id=session_id, + completed_rounds=completed_rounds + 1, + ) + + logger.info( + f"Session termination: processing {len(work_phases_to_process)} work phases " + f"in parallel: {work_phases_to_process}" + ) + + # Create phase records and trigger parallel aggregation + aggregation_tasks = [] + for phase_num in work_phases_to_process: + # Use unified time window calculation + phase_start, phase_end = await self._get_phase_time_window(session, phase_num) + + # Use actual end time for last phase (if ending during work) + if phase_num == max(work_phases_to_process) and current_phase == "work": + phase_end = min(phase_end, end_time) + + # Check if phase record already exists + existing_phase = await self.db.work_phases.get_by_session_and_phase( + session_id, phase_num + ) + + # Skip if already completed or processing + if existing_phase and existing_phase["status"] in ("completed", "processing"): + logger.info( + f"Phase {phase_num} already {existing_phase['status']}, skipping" + ) + continue + + # Create or get phase record + if existing_phase: + phase_id = existing_phase["id"] + else: + phase_id = await self.db.work_phases.create( + session_id=session_id, + phase_number=phase_num, + phase_start_time=phase_start.isoformat(), + phase_end_time=phase_end.isoformat(), + status="pending", + ) + + # Create parallel task (don't await) + task = asyncio.create_task( + self._aggregate_work_phase_activities( + session_id=session_id, + work_phase=phase_num, + phase_start_time=phase_start, + phase_end_time=phase_end, + phase_id=phase_id, + ) + ) + aggregation_tasks.append(task) + + logger.info( + f"Triggered parallel aggregation for {len(aggregation_tasks)} work phases" + ) + + except Exception as e: + # ✅ Isolate all errors to prevent crash + logger.error( + f"Error processing incomplete phases for session {session_id}: {e}", + exc_info=True, + ) + # Don't re-raise - this is a background task + + async def _update_session_metadata( + self, + session_id: str, + end_time: datetime, + actual_work_minutes: int, + status: str, + ) -> None: + """ + Update session metadata in database (fast, non-blocking) + + Args: + session_id: Session ID + end_time: Session end time + actual_work_minutes: Actual work duration in minutes + status: Session status + """ + await self.db.pomodoro_sessions.update( + session_id=session_id, + end_time=end_time.isoformat(), + actual_duration_minutes=actual_work_minutes, + status=status, + processing_status="pending", + ) + + async def _background_finalize_session( + self, + session_id: str, + ) -> None: + """ + Background task: force settlement, cleanup, trigger processing + + CRITICAL: This task MUST NEVER crash or block new sessions. + All errors are logged but do not propagate. + + This runs asynchronously after user-facing state has been updated. + + Args: + session_id: Session ID + """ + try: + logger.info(f"Starting background finalization for session {session_id}") + + # Force settlement: process all pending records + # ✅ Isolate settlement errors to prevent crash + try: + logger.info("Force settling all pending records for session end") + settlement_result = await self.force_settlement(session_id) + if settlement_result.get("success"): + logger.info( + f"✓ Force settlement successful: " + f"{settlement_result['records_processed']['total']} records processed, " + f"{settlement_result['events_generated']} events, " + f"{settlement_result['activities_generated']} activities" + ) + else: + logger.warning( + f"Force settlement had issues but continuing: " + f"{settlement_result.get('error', 'Unknown error')}" + ) + except Exception as e: + # ✅ Isolate settlement errors + logger.error(f"Force settlement failed: {e}", exc_info=True) + + # Trigger batch processing + # ✅ Isolate batch processing errors to prevent crash + try: + await self._trigger_batch_processing(session_id) + except Exception as e: + # ✅ Isolate batch processing errors + logger.error(f"Batch processing failed: {e}", exc_info=True) + + logger.info(f"✓ Background finalization completed for session {session_id}") + + except Exception as e: + # ✅ Catch-all for any unexpected errors + logger.error( + f"Unexpected error in background finalization: {e}", + exc_info=True, + ) + # Mark processing as failed so it doesn't appear stuck + try: + await self.db.pomodoro_sessions.update( + session_id=session_id, + processing_status="failed", + processing_error=f"Background finalization error: {str(e)}", + ) + except: + # Even DB update failure shouldn't crash + pass + + # ============================================================ + # Public Methods - Session Control + # ============================================================ + + async def end_pomodoro(self, status: str = "completed") -> dict[str, Any]: + """ + End current Pomodoro session (manual termination) + + IMPORTANT: This method returns IMMEDIATELY after updating user-facing state. + All heavy processing (settlement, aggregation) happens in background. + + Workflow: + 1. ✅ Validate session (fast) + 2. ✅ Cancel phase timer (fast) + 3. ✅ Update database metadata (fast) + 4. ✅ Flush perception buffers (fast) + 5. ✅ Emit completion event (fast) + 6. ✅ Exit pomodoro mode (fast) + 7. ✅ Clear local state (fast) + 8. ✅ Return immediately to user + 9. 🔄 Start background processing (async, non-blocking) + + Args: + status: Session status ('completed', 'abandoned', 'interrupted') + + Returns: + { + "session_id": str, + "status": str, + "actual_work_minutes": int + } + + Raises: + ValueError: If no active Pomodoro session + """ + if not self.is_active or not self.current_session: + raise ValueError("No active Pomodoro session") + + session_id = self.current_session.id + end_time = datetime.now() + elapsed_duration = self._calculate_elapsed_minutes(end_time) + + # Cancel phase timer if running + self._cancel_phase_timer(session_id) + + try: + # Check if session is too short (< 2 minutes) + if elapsed_duration < _Constants.MIN_SESSION_DURATION_MINUTES: + return await self._handle_too_short_session( + session_id, end_time, elapsed_duration + ) + + # ========== FAST PATH: Immediate user-facing updates ========== + + # Get session data + session = await self.db.pomodoro_sessions.get_by_id(session_id) + + # Calculate actual work duration + actual_work_minutes = ( + await self._calculate_actual_work_minutes(session, end_time) + if session + else int(elapsed_duration) + ) + + # Update database metadata (fast, no heavy processing) + await self._update_session_metadata( + session_id, end_time, actual_work_minutes, status + ) + + # Flush ImageConsumer buffer (fast) + perception_manager = self.coordinator.perception_manager + if perception_manager and perception_manager.image_consumer: + remaining = perception_manager.image_consumer.flush() + logger.debug(f"Flushed {len(remaining)} buffered screenshots") + + # Emit completion event IMMEDIATELY for frontend/clock + self._emit_phase_completion_event(session_id, session, "completed") + logger.info(f"Emitted completion event for session {session_id}") + + # Exit pomodoro mode (stops perception) + await self.coordinator.exit_pomodoro_mode() + + # Clear local state + self._clear_session_state() + + logger.info( + f"✓ Pomodoro session ended (immediate response): {session_id}, " + f"status={status}, elapsed={elapsed_duration:.1f}min, " + f"actual_work={actual_work_minutes}min" + ) + + # ========== BACKGROUND PATH: Heavy processing (non-blocking) ========== + + # Trigger background tasks asynchronously + if session: + # Process incomplete work phases (parallel, background) + asyncio.create_task( + self._process_incomplete_phases(session_id, session, end_time) + ) + + # Trigger background finalization (settlement + batch processing) + asyncio.create_task(self._background_finalize_session(session_id)) + + logger.debug(f"Background processing started for session {session_id}") + + # ========== IMMEDIATE RETURN ========== + + # Count raw records for compatibility with frontend/handler + raw_count = await self.db.raw_records.count_by_session(session_id) + + return { + "session_id": session_id, + "status": status, + "actual_work_minutes": actual_work_minutes, + "raw_records_count": raw_count, # ✅ Added back for compatibility + "message": "Session ended successfully. Background processing started.", + } + + except Exception as e: + logger.error(f"Failed to end Pomodoro session: {e}", exc_info=True) + # Ensure state is cleaned up even on error + self._clear_session_state() + raise + + async def _trigger_batch_processing(self, session_id: str) -> str: + """ + Trigger background batch processing for Pomodoro session + + Creates async task that: + 1. Loads all RawRecords with pomodoro_session_id + 2. Processes through normal pipeline (deferred) + 3. Updates processing_status as it progresses + 4. Emits events for frontend to track progress + + Args: + session_id: Pomodoro session ID + + Returns: + job_id: Processing job identifier + """ + job_id = str(uuid.uuid4()) + + # Create background task + task = asyncio.create_task(self._process_pomodoro_batch(session_id, job_id)) + + # Store task reference + self._processing_tasks[job_id] = task + + logger.debug(f"✓ Batch processing triggered: job={job_id}, session={session_id}") + + return job_id + + async def _process_pomodoro_batch(self, session_id: str, job_id: str): + """ + SIMPLIFIED: Wait for all work phases to complete and trigger LLM evaluation + + NOTE: Batch processing of raw records is removed. All data processing + now happens through phase-level aggregation in _aggregate_work_phase_activities. + + Steps: + 1. Update status to 'processing' + 2. Wait for all work phases to complete (max 5 minutes) + 3. Trigger LLM evaluation (with timeout protection) + 4. Update status to 'completed' + 5. Emit completion event + + Args: + session_id: Pomodoro session ID + job_id: Processing job ID + """ + try: + await self.db.pomodoro_sessions.update( + session_id=session_id, + processing_status="processing", + processing_started_at=datetime.now().isoformat(), + ) + + logger.info(f"→ Waiting for work phases to complete: {session_id}") + + # Wrap entire processing in timeout (max 10 minutes total) + # This prevents processing from hanging indefinitely + try: + await asyncio.wait_for( + self._wait_and_trigger_llm_evaluation(session_id), + timeout=_Constants.TOTAL_PROCESSING_TIMEOUT_SECONDS + ) + except asyncio.TimeoutError: + logger.error( + f"Processing timeout (10 minutes) for session {session_id}, " + f"marking as failed" + ) + await self.db.pomodoro_sessions.update( + session_id=session_id, + processing_status="failed", + processing_error="Processing timeout (10 minutes exceeded)", + ) + self._emit_failure_event(session_id, job_id, "Processing timeout") + self._processing_tasks.pop(job_id, None) + return + + # Update status + await self.db.pomodoro_sessions.update( + session_id=session_id, + processing_status="completed", + processing_completed_at=datetime.now().isoformat(), + ) + + logger.info(f"✓ Pomodoro session completed: {session_id}") + + # Emit completion event + self._emit_completion_event(session_id, job_id, 0) + + # Cleanup task reference + self._processing_tasks.pop(job_id, None) + + except Exception as e: + logger.error( + f"✗ Pomodoro session completion failed: {e}", exc_info=True + ) + await self.db.pomodoro_sessions.update( + session_id=session_id, + processing_status="failed", + processing_error=str(e), + ) + + # Emit failure event + self._emit_failure_event(session_id, job_id, str(e)) + + # Cleanup task reference + self._processing_tasks.pop(job_id, None) + + + + async def _wait_and_trigger_llm_evaluation(self, session_id: str) -> None: + """ + Wait for all work phases to complete successfully, then trigger LLM evaluation. + + This ensures AI analysis only runs after all activity data is ready. + For initial generation, retries are automatic. For subsequent failures, + users can manually retry. + + Args: + session_id: Pomodoro session ID + """ + try: + logger.info(f"Waiting for all work phases to complete for session {session_id}") + + # Get session info + session = await self.db.pomodoro_sessions.get_by_id(session_id) + if not session: + logger.warning(f"Session {session_id} not found, skipping LLM evaluation wait") + return + + # Check if session is still active/pending (not already ended) + session_status = session.get("processing_status", "pending") + if session_status not in ("pending", "processing"): + logger.info( + f"Session {session_id} status is '{session_status}', skipping LLM evaluation wait" + ) + return + + # Use completed_rounds instead of total_rounds + # When user ends session early, only completed_rounds phases are created + completed_rounds = session.get("completed_rounds", 0) + if completed_rounds == 0: + # No work phases completed, skip waiting + logger.info( + f"No completed work phases for session {session_id}, " + f"proceeding directly to LLM evaluation" + ) + await self._compute_and_cache_llm_evaluation(session_id, is_first_attempt=True) + return + + expected_phases = completed_rounds + waited_time = 0 + + # Wait for all completed phases to reach terminal state (completed or failed) + while waited_time < _Constants.MAX_PHASE_WAIT_SECONDS: + # Re-check session status in case it was ended during wait + session = await self.db.pomodoro_sessions.get_by_id(session_id) + if session: + current_status = session.get("processing_status", "pending") + if current_status not in ("pending", "processing"): + logger.info( + f"Session {session_id} status changed to '{current_status}', " + f"stopping LLM evaluation wait" + ) + return + + phases = await self.db.work_phases.get_by_session(session_id) + + # Check if all expected work phases exist and have terminal status + completed_phases = [p for p in phases if p["status"] == "completed"] + failed_phases = [p for p in phases if p["status"] == "failed"] + terminal_phases = completed_phases + failed_phases + + if len(terminal_phases) >= expected_phases: + # All expected phases have reached terminal state + logger.info( + f"All {expected_phases} work phases reached terminal state: " + f"completed={len(completed_phases)}, failed={len(failed_phases)}" + ) + break + + # Still waiting for phases to complete + logger.debug( + f"Waiting for work phases: {len(terminal_phases)}/{expected_phases} complete, " + f"waited {waited_time}s" + ) + + await asyncio.sleep(_Constants.POLL_INTERVAL_SECONDS) + waited_time += _Constants.POLL_INTERVAL_SECONDS + + if waited_time >= _Constants.MAX_PHASE_WAIT_SECONDS: + logger.warning( + f"Timeout waiting for work phases to complete ({_Constants.MAX_PHASE_WAIT_SECONDS}s), " + f"proceeding with LLM evaluation anyway" + ) + + # Now trigger LLM evaluation + await self._compute_and_cache_llm_evaluation(session_id, is_first_attempt=True) + + except Exception as e: + logger.error( + f"Error waiting for phases before LLM evaluation: {e}", + exc_info=True + ) + # Continue to try LLM evaluation anyway + await self._compute_and_cache_llm_evaluation(session_id, is_first_attempt=True) + + async def _compute_and_cache_llm_evaluation( + self, session_id: str, is_first_attempt: bool = False + ) -> None: + """ + Compute LLM focus evaluation and cache to database + + Called after all work phases complete to pre-compute evaluation. + Failures are logged but don't block session completion. + + This method now also updates individual activity focus_scores for + better data granularity and frontend display. + + Args: + session_id: Pomodoro session ID + is_first_attempt: Whether this is the first automatic attempt + """ + try: + logger.info(f"Computing LLM focus evaluation for session {session_id}") + + # Get session and activities + session = await self.db.pomodoro_sessions.get_by_id(session_id) + if not session: + logger.warning(f"Session {session_id} not found for LLM evaluation") + return + + activities = await self.db.activities.get_by_pomodoro_session(session_id) + + if not activities: + logger.info(f"No activities for session {session_id}, skipping LLM evaluation") + return + + # Compute LLM evaluation (session-level) + from llm.focus_evaluator import get_focus_evaluator + + focus_evaluator = get_focus_evaluator() + llm_result = await focus_evaluator.evaluate_focus( + activities=activities, + session_info=session, + ) + + # Cache session-level result to database + await self.db.pomodoro_sessions.update_llm_evaluation( + session_id, llm_result + ) + + logger.info( + f"✓ LLM evaluation cached for session {session_id}: " + f"score={llm_result.get('focus_score')}, " + f"level={llm_result.get('focus_level')}" + ) + + # Update individual activity focus scores for better granularity + await self._update_activity_focus_scores( + session_id, activities, session, focus_evaluator + ) + + except Exception as e: + # Don't crash session completion if LLM evaluation fails + logger.error( + f"Failed to compute LLM evaluation for session {session_id}: {e}", + exc_info=True, + ) + # Continue gracefully - evaluation can be computed on-demand later + + async def _update_activity_focus_scores( + self, + session_id: str, + activities: list[dict[str, Any]], + session: dict[str, Any], + focus_evaluator: "FocusEvaluator", + ) -> None: + """ + Update focus scores for individual activities + + This provides better granularity than session-level scores and enables + per-activity focus analysis in the frontend. + + Args: + session_id: Pomodoro session ID + activities: List of activity dictionaries + session: Session dictionary with user_intent and related_todos + focus_evaluator: FocusEvaluator instance + """ + try: + logger.debug(f"Updating focus scores for {len(activities)} activities") + + # Prepare session context for evaluation + session_context = { + "user_intent": session.get("user_intent"), + "related_todos": session.get("related_todos", []), + } + + # Evaluate and update each activity + activity_scores = [] + for activity in activities: + try: + # Evaluate single activity focus + activity_eval = await focus_evaluator.evaluate_activity_focus( + activity=activity, + session_context=session_context, + ) + + focus_score = activity_eval.get("focus_score", 50.0) + activity_scores.append({ + "activity_id": activity["id"], + "focus_score": focus_score, + }) + + logger.debug( + f"Activity '{activity.get('title', 'Untitled')[:30]}' " + f"focus_score: {focus_score}" + ) + + except Exception as e: + logger.warning( + f"Failed to evaluate activity {activity.get('id')}: {e}, " + f"using default score" + ) + # Use default score on failure + activity_scores.append({ + "activity_id": activity["id"], + "focus_score": 50.0, + }) + + # Batch update all activity focus scores + if activity_scores: + updated_count = await self.db.activities.batch_update_focus_scores( + activity_scores + ) + logger.info( + f"✓ Updated focus_scores for {updated_count} activities " + f"in session {session_id}" + ) + + except Exception as e: + logger.error( + f"Failed to update activity focus scores for session {session_id}: {e}", + exc_info=True, + ) + # Non-critical error, continue gracefully + + async def check_orphaned_sessions(self) -> int: + """ + Check for orphaned sessions from previous runs + + Orphaned sessions are active sessions that were not properly closed + (e.g., due to app crash or system shutdown). + + This should be called on application startup. + + Returns: + Number of orphaned sessions found and recovered + """ + try: + orphaned = await self.db.pomodoro_sessions.get_by_status("active") + + if not orphaned: + return 0 + + logger.warning(f"Found {len(orphaned)} orphaned Pomodoro session(s)") + + for session in orphaned: + session_id = session["id"] + recovery_time = datetime.now() + + # Calculate actual work duration + completed_rounds = session.get("completed_rounds", 0) + work_duration = session.get("work_duration_minutes", 25) + actual_work_minutes = completed_rounds * work_duration + + # If session was interrupted during a work phase, add actual time worked + if session.get("current_phase") == "work": + phase_start_time_str = session.get("phase_start_time") + if phase_start_time_str: + try: + phase_start_time = datetime.fromisoformat(phase_start_time_str) + # Calculate actual time worked in interrupted phase (in minutes) + current_phase_minutes = (recovery_time - phase_start_time).total_seconds() / 60 + actual_work_minutes += int(current_phase_minutes) + logger.info( + f"Orphaned session {session_id} interrupted during work phase: " + f"adding {int(current_phase_minutes)}min to total" + ) + except Exception as e: + # Fallback: if phase_start_time parsing fails, use full work_duration + actual_work_minutes += work_duration + logger.warning( + f"Failed to parse phase_start_time for orphaned session {session_id}, " + f"using full work_duration: {e}" + ) + else: + # Fallback: if no phase_start_time, use full work_duration + actual_work_minutes += work_duration + logger.warning( + f"No phase_start_time for orphaned session {session_id}, " + f"using full work_duration ({work_duration}min)" + ) + + # Auto-end as 'abandoned' (SIMPLIFIED: interrupted → abandoned) + await self.db.pomodoro_sessions.update( + session_id=session_id, + end_time=recovery_time.isoformat(), + actual_duration_minutes=actual_work_minutes, + status="abandoned", + processing_status="pending", + ) + + # Trigger batch processing + await self._trigger_batch_processing(session_id) + + logger.info( + f"✓ Recovered orphaned session: {session_id}, " + f"actual_work={actual_work_minutes}min, triggering analysis" + ) + + return len(orphaned) + + except Exception as e: + logger.error(f"Failed to check orphaned sessions: {e}", exc_info=True) + return 0 + + async def get_current_session_info(self) -> dict[str, Any] | None: + """ + Get current session information with rounds and phase data + + Returns: + Session info dict or None if no active session + """ + if not self.is_active or not self.current_session: + return None + + # Fetch full session info from database to get all fields + session_record = await self.db.pomodoro_sessions.get_by_id( + self.current_session.id + ) + + if not session_record: + return None + + now = datetime.now() + elapsed_minutes = ( + now - self.current_session.start_time + ).total_seconds() / 60 + + # Get phase information + current_phase = session_record.get("current_phase", "work") + phase_start_time_str = session_record.get("phase_start_time") + work_duration = session_record.get("work_duration_minutes", 25) + break_duration = session_record.get("break_duration_minutes", 5) + + # Calculate remaining time in current phase + remaining_phase_seconds = None + if phase_start_time_str: + try: + phase_start = datetime.fromisoformat(phase_start_time_str) + phase_elapsed = (now - phase_start).total_seconds() + + # Determine phase duration + phase_duration_seconds = ( + work_duration * 60 + if current_phase == "work" + else break_duration * 60 + ) + + remaining_phase_seconds = max( + 0, int(phase_duration_seconds - phase_elapsed) + ) + except Exception as e: + logger.warning(f"Failed to calculate remaining time: {e}") + + session_info = { + "session_id": self.current_session.id, + "user_intent": self.current_session.user_intent, + "start_time": self.current_session.start_time.isoformat(), + "elapsed_minutes": int(elapsed_minutes), + "planned_duration_minutes": self.current_session.duration_minutes, + "associated_todo_id": session_record.get("associated_todo_id"), + "associated_todo_title": None, + # Rounds data + "work_duration_minutes": work_duration, + "break_duration_minutes": break_duration, + "total_rounds": session_record.get("total_rounds", 4), + "current_round": session_record.get("current_round", 1), + "current_phase": current_phase, + "phase_start_time": phase_start_time_str, + "completed_rounds": session_record.get("completed_rounds", 0), + "remaining_phase_seconds": remaining_phase_seconds, + } + + # If there's an associated TODO, fetch its title + todo_id = session_info["associated_todo_id"] + if todo_id: + try: + # Ensure todo_id is a string for type safety + todo_id_str = str(todo_id) if not isinstance(todo_id, str) else todo_id + todo = await self.db.todos.get_by_id(todo_id_str) + if todo and not todo.get("deleted"): + session_info["associated_todo_title"] = todo.get("title") + except Exception as e: + logger.warning( + f"Failed to fetch TODO title for session {self.current_session.id}: {e}" + ) + + return session_info + + def _classify_aggregation_error(self, error: Exception) -> str: + """ + Classify aggregation errors for better user feedback. + + Returns: + - 'no_actions_found': No user activity during phase + - 'llm_clustering_failed': LLM API call failed + - 'supervisor_validation_failed': Activity validation failed + - 'database_save_failed': Database operation failed + - 'unknown_error': Unclassified error + """ + error_str = str(error).lower() + + if "no actions found" in error_str or "no action" in error_str: + return "no_actions_found" + elif "clustering" in error_str or "llm" in error_str or "api" in error_str: + return "llm_clustering_failed" + elif "supervisor" in error_str or "validation" in error_str: + return "supervisor_validation_failed" + elif "database" in error_str or "sql" in error_str: + return "database_save_failed" + else: + return "unknown_error" + + async def _get_phase_time_window( + self, + session: dict[str, Any], + phase_number: int + ) -> tuple[datetime, datetime]: + """ + Unified phase time window calculation logic + + IMPORTANT: Uses user-configured durations from session record, NOT hardcoded defaults. + + Priority: + 1. Use actual times from work_phases table (if phase completed) + 2. Calculate from session start + user-configured durations (fallback) + + Args: + session: Session record dict from database + phase_number: Phase number (1-based) + + Returns: + Tuple of (phase_start_time, phase_end_time) + """ + try: + # Try to get actual phase record from database (most accurate) + phase_record = await self.db.work_phases.get_by_session_and_phase( + session['id'], phase_number + ) + + if phase_record and phase_record.get('phase_start_time'): + # Use actual recorded times (preferred) + start_time = datetime.fromisoformat(phase_record['phase_start_time']) + end_time_str = phase_record.get('phase_end_time') + end_time = datetime.fromisoformat(end_time_str) if end_time_str else datetime.now() + + logger.debug( + f"Using actual phase times from DB: session={session['id']}, " + f"phase={phase_number}, start={start_time.isoformat()}, end={end_time.isoformat()}" + ) + + return (start_time, end_time) + + except Exception as e: + logger.warning(f"Failed to query phase record from DB: {e}") + + # Fallback: Calculate from session start + user-configured durations + # ⚠️ CRITICAL: Use user-configured durations, NOT hardcoded values + session_start = datetime.fromisoformat(session['start_time']) + work_duration = session.get('work_duration_minutes', 25) # User-configured + break_duration = session.get('break_duration_minutes', 5) # User-configured + + # Calculate offset for this phase + # Phase 1: offset = 0 + # Phase 2: offset = work_duration + break_duration + # Phase 3: offset = 2 * (work_duration + break_duration) + offset_minutes = (phase_number - 1) * (work_duration + break_duration) + + start_time = session_start + timedelta(minutes=offset_minutes) + end_time = start_time + timedelta(minutes=work_duration) + + logger.debug( + f"Calculated phase times: session={session['id']}, phase={phase_number}, " + f"work_duration={work_duration}min, break_duration={break_duration}min, " + f"start={start_time.isoformat()}, end={end_time.isoformat()}" + ) + + return (start_time, end_time) + + async def _aggregate_work_phase_activities( + self, + session_id: str, + work_phase: int, + phase_start_time: datetime, + phase_end_time: datetime, + phase_id: str | None = None, + ) -> None: + """ + Aggregate actions into activities for a work phase WITH SIMPLIFIED RETRY. + + Retry Strategy: + - Attempt 1: Immediate + - Attempt 2: After 10 seconds + - After 2 attempts: Mark as 'failed' (user can manually retry) + + Args: + session_id: Session ID + work_phase: Phase number (1-4) + phase_start_time: Phase start time + phase_end_time: Phase end time + phase_id: Existing phase record ID (optional) + """ + try: + # Get or create phase record + if not phase_id: + existing_phase = await self.db.work_phases.get_by_session_and_phase( + session_id, work_phase + ) + if existing_phase: + phase_id = existing_phase["id"] + else: + phase_id = await self.db.work_phases.create( + session_id=session_id, + phase_number=work_phase, + phase_start_time=phase_start_time.isoformat(), + phase_end_time=phase_end_time.isoformat(), + status="pending", + ) + + # SIMPLIFIED RETRY LOOP: Only 1 retry + for attempt in range(_Constants.MAX_RETRIES + 1): + try: + # Update status to processing + await self.db.work_phases.update_status( + phase_id, "processing", None, attempt + ) + + logger.info( + f"Processing work phase: session={session_id}, " + f"phase={work_phase}, attempt={attempt + 1}/{_Constants.MAX_RETRIES + 1}" + ) + + # Get SessionAgent from coordinator + session_agent = self.coordinator.session_agent + if not session_agent: + raise ValueError("SessionAgent not available") + + # Delegate to SessionAgent for actual aggregation + activities = await session_agent.aggregate_work_phase( + session_id=session_id, + work_phase=work_phase, + phase_start_time=phase_start_time, + phase_end_time=phase_end_time, + ) + + # Validate result + if not activities: + raise ValueError("No actions found for work phase") + + # SUCCESS - Mark completed + await self.db.work_phases.mark_completed(phase_id, len(activities)) + + logger.info( + f"✓ Work phase aggregation completed: " + f"session={session_id}, phase={work_phase}, " + f"activities={len(activities)}" + ) + + # Emit success event + emit_pomodoro_work_phase_completed(session_id, work_phase, len(activities)) + + return # Exit retry loop on success + + except Exception as e: + # Classify error for better reporting + error_type = self._classify_aggregation_error(e) + error_message = f"{error_type}: {str(e)}" + + logger.warning( + f"Work phase aggregation attempt {attempt + 1} failed: " + f"{error_message}" + ) + + if attempt < _Constants.MAX_RETRIES: + # Schedule retry after 10 seconds + new_retry_count = await self.db.work_phases.increment_retry_count( + phase_id + ) + + await self.db.work_phases.update_status( + phase_id, "pending", error_message, new_retry_count + ) + + logger.info( + f"Retrying work phase in {_Constants.RETRY_DELAY_SECONDS}s " + f"(retry {new_retry_count}/{_Constants.MAX_RETRIES})" + ) + + await asyncio.sleep(_Constants.RETRY_DELAY_SECONDS) + else: + # All retries exhausted - mark as failed + # User can manually retry via API + await self.db.work_phases.update_status( + phase_id, "failed", error_message, _Constants.MAX_RETRIES + ) + + logger.error( + f"✗ Work phase aggregation failed after {_Constants.MAX_RETRIES + 1} attempts: " + f"session={session_id}, phase={work_phase}, error={error_message}" + ) + + # Emit failure event + emit_pomodoro_work_phase_failed(session_id, work_phase, error_message) + + return # Don't raise - allow other phases to continue + + except Exception as e: + # Outer exception handler (should rarely trigger) + logger.error( + f"Unexpected error in work phase aggregation: {e}", exc_info=True + ) + + def get_current_session_id(self) -> str | None: + """ + Get current active Pomodoro session ID + + Returns: + Session ID if a Pomodoro session is active, None otherwise + """ + if self.is_active and self.current_session: + return self.current_session.id + return None + + async def force_settlement(self, session_id: str) -> dict[str, Any]: + """ + Force settlement of all pending records for phase completion + + This method ensures no data loss by: + 1. Flushing ImageConsumer buffered screenshots + 2. Collecting all unprocessed records from storage + 3. Immediately processing them through the pipeline + + Called during phase transitions to guarantee all captured actions + are processed into events before the phase ends. + + Args: + session_id: Pomodoro session ID + + Returns: + Dict with settlement results including counts of processed records + """ + logger.info(f"Starting force settlement for session: {session_id}") + + all_records = [] + records_count = { + "image_consumer": 0, + "storage": 0, + "event_buffer": 0, + "total": 0 + } + + try: + # Step 1: Flush ImageConsumer buffered screenshots + perception_manager = self.coordinator.perception_manager + if perception_manager and perception_manager.image_consumer: + logger.debug("Flushing ImageConsumer buffer...") + buffered_records = perception_manager.image_consumer.flush() + if buffered_records: + all_records.extend(buffered_records) + records_count["image_consumer"] = len(buffered_records) + logger.info(f"Flushed {len(buffered_records)} records from ImageConsumer") + + # Step 2: Get all records from SlidingWindowStorage + if perception_manager and perception_manager.storage: + logger.debug("Collecting records from SlidingWindowStorage...") + storage_records = perception_manager.storage.get_records() + if storage_records: + all_records.extend(storage_records) + records_count["storage"] = len(storage_records) + logger.info(f"Collected {len(storage_records)} records from SlidingWindowStorage") + + # Step 3: Get all events from EventBuffer + if perception_manager and perception_manager.event_buffer: + logger.debug("Collecting events from EventBuffer...") + event_records = perception_manager.event_buffer.get_all() + if event_records: + all_records.extend(event_records) + records_count["event_buffer"] = len(event_records) + logger.info(f"Collected {len(event_records)} events from EventBuffer") + + records_count["total"] = len(all_records) + + # Step 4: Sort records by timestamp to ensure correct processing order + all_records.sort(key=lambda r: r.timestamp) + + # Step 5: Force process all records immediately + if all_records: + logger.info( + f"Force processing {len(all_records)} total records for phase settlement" + ) + result = await self.coordinator.force_process_records(all_records) + + events_count = len(result.get("events", [])) + activities_count = len(result.get("activities", [])) + + logger.info( + f"✓ Force settlement completed: " + f"{records_count['total']} records → {events_count} events → {activities_count} activities" + ) + + return { + "success": True, + "records_processed": records_count, + "events_generated": events_count, + "activities_generated": activities_count, + "result": result + } + else: + logger.info("No pending records to settle") + return { + "success": True, + "records_processed": records_count, + "events_generated": 0, + "activities_generated": 0, + "message": "No pending records" + } + + except Exception as e: + logger.error(f"Force settlement failed for session {session_id}: {e}", exc_info=True) + return { + "success": False, + "error": str(e), + "records_processed": records_count + } diff --git a/backend/core/protocols.py b/backend/core/protocols.py index d34dcec..0441870 100644 --- a/backend/core/protocols.py +++ b/backend/core/protocols.py @@ -246,6 +246,50 @@ async def get_all( class KnowledgeRepositoryProtocol(Protocol): """Protocol for knowledge repository operations""" + async def save( + self, + knowledge_id: str, + title: str, + description: str, + keywords: List[str], + *, + created_at: Optional[str] = None, + source_action_id: Optional[str] = None, + favorite: bool = False, + ) -> None: + """Save or update knowledge""" + ... + + async def get_list(self, include_deleted: bool = False) -> List[Dict[str, Any]]: + """Get knowledge list""" + ... + + async def delete(self, knowledge_id: str) -> None: + """Soft delete knowledge""" + ... + + async def hard_delete(self, knowledge_id: str) -> bool: + """Hard delete knowledge (permanent deletion)""" + ... + + async def hard_delete_batch(self, knowledge_ids: List[str]) -> int: + """Hard delete multiple knowledge entries (permanent deletion)""" + ... + + async def update( + self, + knowledge_id: str, + title: str, + description: str, + keywords: List[str], + ) -> None: + """Update knowledge""" + ... + + async def toggle_favorite(self, knowledge_id: str) -> Optional[bool]: + """Toggle favorite status""" + ... + async def insert(self, knowledge_data: Dict[str, Any]) -> int: """Insert new knowledge""" ... diff --git a/backend/core/settings.py b/backend/core/settings.py index 153f394..bcb6990 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -5,7 +5,7 @@ import json import os -from typing import Any, Dict, Optional, cast +from typing import Any, Dict, List, Optional, cast from core.logger import get_logger from core.paths import get_data_dir @@ -252,17 +252,6 @@ def set_screenshot_path(self, path: str) -> bool: logger.error(f"Failed to update screenshot save path in config: {e}") return False - def get_screenshot_force_save_interval(self) -> float: - """Get screenshot force save interval (seconds) - - Returns the interval in seconds after which a screenshot will be force-saved - even if it appears to be a duplicate. Default is 60 seconds (1 minute). - """ - if not self.config_loader: - return 60.0 # Default 1 minute - - return float(self.config_loader.get("screenshot.force_save_interval", 60.0)) - # ======================== Live2D Configuration ======================== @staticmethod @@ -674,12 +663,398 @@ def get(self, key: str, default: Any = None) -> Any: return value def get_language(self) -> str: - """Get current language setting + """Get current language setting from database Returns: Language code (zh or en), defaults to zh """ - return self.get("language.default_language", "zh") + if not self.db: + return "zh" + + try: + # Read from database (user-level setting) + language = self.db.settings.get("language.default_language", "zh") + + # Validate value + if language not in ["zh", "en"]: + return "zh" + + return language + except Exception as e: + logger.warning(f"Failed to read language from database: {e}") + return "zh" + + # ======================== Pomodoro Buffering Configuration ======================== + + def get_pomodoro_buffering_config(self) -> Dict[str, Any]: + """Get Pomodoro screenshot buffering configuration + + Screenshot buffering improves performance by batching screenshots before + sending to LLM for processing. This reduces API calls and improves response time. + + Returns: + Dictionary with buffering configuration: + - enabled: Whether buffering is enabled (default: True) + - count_threshold: Number of screenshots to trigger batch (default: 20) + Lowered from 50 to 20 to match action extraction threshold. + At 1 screenshot/sec, this means 20 seconds worst case delay. + - time_threshold: Seconds elapsed to trigger batch (default: 30.0) + Lowered from 60 to 30 seconds to reduce latency during idle periods. + - max_buffer_size: Emergency flush limit (default: 200) + Safety limit to prevent memory issues if processing is slow. + - processing_timeout: Timeout for LLM calls in seconds (default: 720.0) + 12 minutes timeout for batch processing. If exceeded, buffer is reset. + + Note: After Phase 1 optimization (Jan 2026), these settings work well with + the simplified retry mechanism (1 retry instead of 4). + """ + return { + "enabled": self.get("pomodoro.enable_screenshot_buffering", True), + # CRITICAL FIX: Lowered from 50 to 20 to match action extraction threshold + # This ensures screenshots are batched more frequently and don't get stuck in buffer + # At 1 screenshot/sec, 20 screenshots = 20 seconds worst case delay + "count_threshold": int(self.get("pomodoro.screenshot_buffer_count_threshold", 20)), + # CRITICAL FIX: Lowered from 60 to 30 seconds to reduce latency + # This prevents screenshots from being buffered too long during idle periods + "time_threshold": float(self.get("pomodoro.screenshot_buffer_time_threshold", 30.0)), + "max_buffer_size": int(self.get("pomodoro.screenshot_buffer_max_size", 200)), + "processing_timeout": float(self.get("pomodoro.screenshot_buffer_processing_timeout", 720.0)), + } + + # ======================== Pomodoro Goal Configuration ======================== + + @staticmethod + def _default_pomodoro_goal_settings() -> Dict[str, Any]: + """Get default pomodoro goal configuration""" + return { + "daily_focus_goal_minutes": 120, # 2 hours + "weekly_focus_goal_minutes": 600, # 10 hours + } + + def get_pomodoro_goal_settings(self) -> Dict[str, Any]: + """Get Pomodoro goal configuration from database + + Returns: + Dictionary with goal configuration: + - daily_focus_goal_minutes: Daily focus time goal in minutes (default: 120) + - weekly_focus_goal_minutes: Weekly focus time goal in minutes (default: 600) + """ + defaults = self._default_pomodoro_goal_settings() + + if not self.db: + logger.warning("Database not initialized, using defaults") + return defaults + + try: + merged = self._load_dict_from_db("pomodoro", defaults) + + # Validate ranges: daily 30-720 minutes (0.5-12h), weekly 60-5040 minutes (1-84h) + merged["daily_focus_goal_minutes"] = max( + 30, min(720, int(merged.get("daily_focus_goal_minutes", 120))) + ) + merged["weekly_focus_goal_minutes"] = max( + 60, min(5040, int(merged.get("weekly_focus_goal_minutes", 600))) + ) + + return merged + except Exception as exc: + logger.warning(f"Failed to read Pomodoro goal settings from database, using defaults: {exc}") + return defaults + + def update_pomodoro_goal_settings(self, updates: Dict[str, Any]) -> Dict[str, Any]: + """Update Pomodoro goal configuration values in database + + Args: + updates: Dictionary with goal updates (daily_focus_goal_minutes, weekly_focus_goal_minutes) + + Returns: + Updated goal configuration dictionary + """ + if not self.db: + logger.error("Database not initialized") + return self._default_pomodoro_goal_settings() + + current = self.get_pomodoro_goal_settings() + merged = current.copy() + + if "daily_focus_goal_minutes" in updates: + merged["daily_focus_goal_minutes"] = max(30, min(720, int(updates["daily_focus_goal_minutes"]))) + if "weekly_focus_goal_minutes" in updates: + merged["weekly_focus_goal_minutes"] = max(60, min(5040, int(updates["weekly_focus_goal_minutes"]))) + + try: + self._save_dict_to_db("pomodoro", merged) + logger.debug("✓ Pomodoro goal settings updated in database") + except Exception as exc: + logger.error(f"Failed to update Pomodoro goal settings in database: {exc}") + + return merged + + def get_screenshot_screen_settings(self) -> List[Dict[str, Any]]: + """Get screenshot screen settings from database + + Returns: + List of screen settings dictionaries + """ + if not self.db: + logger.warning("Database not initialized, returning empty screen settings") + return [] + + try: + # Read screen settings from database + all_settings = self.db.settings.get_all() + + # Group settings by screen index + screens_dict: Dict[int, Dict[str, Any]] = {} + for key, value in all_settings.items(): + if key.startswith("screenshot.screen_settings."): + # Extract screen index and property name + # Format: screenshot.screen_settings.{index}.{property} + parts = key.split(".", 3) + if len(parts) >= 4: + try: + screen_index = int(parts[2]) + property_name = parts[3] + + if screen_index not in screens_dict: + screens_dict[screen_index] = {} + + # Add the property to the screen dict + screens_dict[screen_index][property_name] = value + except (ValueError, IndexError) as e: + logger.warning(f"Failed to parse screen setting key {key}: {e}") + continue + + # Convert to list and sort by monitor_index + screens = list(screens_dict.values()) + screens.sort(key=lambda x: x.get("monitor_index", 0)) + + logger.debug(f"✓ Loaded {len(screens)} screen settings from database") + return screens + except Exception as e: + logger.error(f"Failed to read screenshot screen settings from database: {e}") + import traceback + logger.error(traceback.format_exc()) + return [] + + def get_font_size(self) -> str: + """Get current font size setting from database + + Returns: + Font size (small, default, large, extra-large), defaults to default + """ + if not self.db: + return "default" + + try: + # Read from database (user-level setting) + font_size = self.db.settings.get("ui.font_size", "default") + + # Validate value + valid_sizes = ["small", "default", "large", "extra-large"] + if font_size not in valid_sizes: + return "default" + + return font_size + except Exception as e: + logger.warning(f"Failed to read font size from database: {e}") + return "default" + + # ======================== Voice and Clock Settings ======================== + + @staticmethod + def _default_voice_settings() -> Dict[str, Any]: + """Get default notification sound settings (kept as voice for backward compatibility)""" + return { + "enabled": True, + "volume": 0.8, + "sound_theme": "8bit", + "custom_sounds": None + } + + def get_voice_settings(self) -> Dict[str, Any]: + """Get voice settings from database""" + defaults = self._default_voice_settings() + + if not self.db: + logger.warning("Database not initialized, using defaults") + return defaults + + try: + merged = self._load_dict_from_db("voice", defaults) + + # Validate and normalize values + merged["enabled"] = bool(merged.get("enabled", True)) + volume = merged.get("volume", 0.8) + merged["volume"] = max(0.0, min(1.0, float(volume))) + + # Migration: convert old language setting to sound_theme + if "language" in merged and "sound_theme" not in merged: + merged["sound_theme"] = "8bit" # Default theme for migrated settings + logger.debug("Migrated old voice.language to voice.sound_theme") + + sound_theme = merged.get("sound_theme", "8bit") + merged["sound_theme"] = sound_theme if sound_theme in ["8bit", "16bit", "custom"] else "8bit" + + # Handle custom sounds (JSON) + custom_sounds = merged.get("custom_sounds") + if custom_sounds and isinstance(custom_sounds, str): + try: + merged["custom_sounds"] = json.loads(custom_sounds) + except json.JSONDecodeError: + merged["custom_sounds"] = None + elif not isinstance(custom_sounds, dict): + merged["custom_sounds"] = None + + return merged + except Exception as exc: + logger.warning(f"Failed to read voice settings from database, using defaults: {exc}") + return defaults + + def update_voice_settings(self, updates: Dict[str, Any]) -> Dict[str, Any]: + """Update notification sound settings in database (kept as voice for backward compatibility)""" + if not self.db: + logger.error("Database not initialized") + return self._default_voice_settings() + + current = self.get_voice_settings() + merged = current.copy() + + if "enabled" in updates: + merged["enabled"] = bool(updates.get("enabled", True)) + if "volume" in updates: + volume = float(updates.get("volume", 0.8)) + merged["volume"] = max(0.0, min(1.0, volume)) + if "sound_theme" in updates: + sound_theme = updates.get("sound_theme", "8bit") + merged["sound_theme"] = sound_theme if sound_theme in ["8bit", "16bit", "custom"] else "8bit" + if "custom_sounds" in updates: + custom_sounds = updates.get("custom_sounds") + merged["custom_sounds"] = custom_sounds if isinstance(custom_sounds, dict) else None + + try: + self._save_dict_to_db("voice", merged) + logger.debug("✓ Notification sound settings updated in database") + except Exception as exc: + logger.error(f"Failed to update notification sound settings in database: {exc}") + + return merged + + @staticmethod + def _default_clock_settings() -> Dict[str, Any]: + """Get default clock settings""" + return { + "enabled": True, + "position": "bottom-right", + "size": "medium", + "custom_x": None, + "custom_y": None, + "custom_width": None, + "custom_height": None, + "use_custom_position": False + } + + def get_clock_settings(self) -> Dict[str, Any]: + """Get clock settings from database""" + defaults = self._default_clock_settings() + + if not self.db: + logger.warning("Database not initialized, using defaults") + return defaults + + try: + merged = self._load_dict_from_db("clock", defaults) + + # Validate and normalize values + merged["enabled"] = bool(merged.get("enabled", True)) + position = merged.get("position", "bottom-right") + if position not in ["bottom-right", "bottom-left", "top-right", "top-left"]: + position = "bottom-right" + merged["position"] = position + size = merged.get("size", "medium") + if size not in ["small", "medium", "large"]: + size = "medium" + merged["size"] = size + + # Custom position fields + merged["custom_x"] = merged.get("custom_x") + merged["custom_y"] = merged.get("custom_y") + merged["custom_width"] = merged.get("custom_width") + merged["custom_height"] = merged.get("custom_height") + merged["use_custom_position"] = bool(merged.get("use_custom_position", False)) + + return merged + except Exception as exc: + logger.warning(f"Failed to read clock settings from database, using defaults: {exc}") + return defaults + + def update_clock_settings(self, updates: Dict[str, Any]) -> Dict[str, Any]: + """Update clock settings in database""" + if not self.db: + logger.error("Database not initialized") + return self._default_clock_settings() + + current = self.get_clock_settings() + merged = current.copy() + + if "enabled" in updates: + merged["enabled"] = bool(updates.get("enabled", True)) + if "position" in updates: + position = updates.get("position", "bottom-right") + if position in ["bottom-right", "bottom-left", "top-right", "top-left"]: + merged["position"] = position + if "size" in updates: + size = updates.get("size", "medium") + if size in ["small", "medium", "large"]: + merged["size"] = size + + # Custom position fields + if "custom_x" in updates: + merged["custom_x"] = updates.get("custom_x") + if "custom_y" in updates: + merged["custom_y"] = updates.get("custom_y") + if "custom_width" in updates: + merged["custom_width"] = updates.get("custom_width") + if "custom_height" in updates: + merged["custom_height"] = updates.get("custom_height") + if "use_custom_position" in updates: + merged["use_custom_position"] = bool(updates.get("use_custom_position", False)) + + try: + self._save_dict_to_db("clock", merged) + logger.debug("✓ Clock settings updated in database") + except Exception as exc: + logger.error(f"Failed to update clock settings in database: {exc}") + + return merged + + def set_font_size(self, font_size: str) -> bool: + """Set application font size + + Args: + font_size: Font size (small, default, large, extra-large) + + Returns: + True if successful, False otherwise + """ + # Validate font size + valid_sizes = ["small", "default", "large", "extra-large"] + if font_size not in valid_sizes: + logger.error(f"Invalid font size: {font_size}. Must be one of {valid_sizes}") + return False + + try: + # Save to database instead of TOML file + # This ensures only user-level settings are stored, not system settings + self._save_dict_to_db("ui", {"font_size": font_size}) + + # Update cache to ensure immediate effect + self._config_cache["ui.font_size"] = font_size + logger.debug(f"✓ Application font size updated to: {font_size}") + return True + except Exception as e: + logger.error(f"Failed to set font size: {e}") + return False def set_language(self, language: str) -> bool: """Set application language @@ -690,39 +1065,83 @@ def set_language(self, language: str) -> bool: Returns: True if successful, False otherwise """ - if not self.config_loader: - logger.error("Configuration loader not initialized") - return False - # Validate language code if language not in ["zh", "en"]: logger.error(f"Invalid language code: {language}. Must be 'zh' or 'en'") return False try: - # Update configuration file - result = self.config_loader.set("language.default_language", language) - if result: - # Update cache to ensure immediate effect - self._config_cache["language.default_language"] = language - logger.debug(f"✓ Application language updated to: {language}") - return result + # Save to database instead of TOML file + # This ensures only user-level settings are stored, not system settings + self._save_dict_to_db("language", {"default_language": language}) + + # Update cache to ensure immediate effect + self._config_cache["language.default_language"] = language + logger.debug(f"✓ Application language updated to: {language}") + return True except Exception as e: logger.error(f"Failed to set language: {e}") return False def set(self, key: str, value: Any) -> bool: - """Set any configuration item""" - if not self.config_loader: - logger.error("Configuration loader not initialized") - return False + """Set any configuration item + + Determines the appropriate storage location based on configuration type: + - User-level settings: Saved to TOML config file + - System-level settings: Saved to database + """ + # User-level configuration keys that should be saved to TOML file + user_level_keys = { + "ui.font_size", + "language.default_language", + "screenshot.save_path", + "screenshot.force_save_interval", + "database.path", + } try: - result = self.config_loader.set(key, value) - if result: - # Invalidate cache when config is modified - self._invalidate_cache() - return result + # Check if this is a user-level setting + is_user_level = key in user_level_keys + + if is_user_level: + # Save user-level setting to TOML config file + if not self.config_loader: + logger.error("Configuration loader not initialized") + return False + + result = self.config_loader.set(key, value) + if result: + # Update cache to ensure immediate effect + self._config_cache[key] = value + # Invalidate cache when config is modified + self._invalidate_cache() + return result + else: + # Save system-level setting to database + # Parse the key to extract prefix and individual key + if "." in key: + prefix, individual_key = key.rsplit(".", 1) + else: + prefix = key + individual_key = "value" + + # Special handling for complex data structures like screen_settings array + if key == "screenshot.screen_settings" and isinstance(value, list): + # Save each screen setting as a separate database entry + for idx, screen in enumerate(value): + screen_key = f"screenshot.screen_settings.{idx}" + if isinstance(screen, dict): + self._save_dict_to_db(screen_key, screen) + else: + self._save_dict_to_db(screen_key, {"value": screen}) + else: + self._save_dict_to_db(prefix, {individual_key: value}) + + # Update cache to ensure immediate effect + self._config_cache[key] = value + logger.debug(f"✓ System configuration {key} saved to database") + return True + except Exception as e: logger.error(f"Failed to set configuration {key}: {e}") return False diff --git a/backend/core/sqls/__init__.py b/backend/core/sqls/__init__.py index a59d4b2..7e00f6b 100644 --- a/backend/core/sqls/__init__.py +++ b/backend/core/sqls/__init__.py @@ -3,6 +3,6 @@ Provides centralized SQL statement management for better maintainability """ -from . import migrations, queries, schema +from . import queries, schema -__all__ = ["schema", "migrations", "queries"] +__all__ = ["schema", "queries"] diff --git a/backend/core/sqls/migrations.py b/backend/core/sqls/migrations.py deleted file mode 100644 index ad9b878..0000000 --- a/backend/core/sqls/migrations.py +++ /dev/null @@ -1,178 +0,0 @@ -""" -Database migration SQL statements -Contains all ALTER TABLE and data migration statements -""" - -# Events table migrations -CREATE_EVENTS_NEW_TABLE = """ - CREATE TABLE events_new ( - id TEXT PRIMARY KEY, - start_time TEXT, - end_time TEXT, - type TEXT, - summary TEXT, - source_data TEXT, - title TEXT DEFAULT '', - description TEXT DEFAULT '', - keywords TEXT, - timestamp TEXT, - created_at TEXT DEFAULT CURRENT_TIMESTAMP - ) -""" - -MIGRATE_EVENTS_DATA = """ - INSERT INTO events_new ( - id, start_time, end_time, type, summary, source_data, - title, description, keywords, timestamp, created_at - ) - SELECT - id, start_time, end_time, type, summary, source_data, - COALESCE(title, SUBSTR(COALESCE(summary, ''), 1, 100)), - COALESCE(description, COALESCE(summary, '')), - keywords, timestamp, created_at - FROM events -""" - -DROP_OLD_EVENTS_TABLE = "DROP TABLE events" - -RENAME_EVENTS_TABLE = "ALTER TABLE events_new RENAME TO events" - -ADD_EVENTS_TITLE_COLUMN = """ - ALTER TABLE events - ADD COLUMN title TEXT DEFAULT '' -""" - -UPDATE_EVENTS_TITLE = """ - UPDATE events - SET title = SUBSTR(COALESCE(summary, ''), 1, 100) - WHERE title = '' OR title IS NULL -""" - -ADD_EVENTS_DESCRIPTION_COLUMN = """ - ALTER TABLE events - ADD COLUMN description TEXT DEFAULT '' -""" - -UPDATE_EVENTS_DESCRIPTION = """ - UPDATE events - SET description = COALESCE(summary, '') - WHERE description = '' OR description IS NULL -""" - -ADD_EVENTS_KEYWORDS_COLUMN = """ - ALTER TABLE events - ADD COLUMN keywords TEXT DEFAULT NULL -""" - -ADD_EVENTS_TIMESTAMP_COLUMN = """ - ALTER TABLE events - ADD COLUMN timestamp TEXT DEFAULT NULL -""" - -UPDATE_EVENTS_TIMESTAMP = """ - UPDATE events - SET timestamp = start_time - WHERE timestamp IS NULL AND start_time IS NOT NULL -""" - -ADD_EVENTS_DELETED_COLUMN = """ - ALTER TABLE events - ADD COLUMN deleted BOOLEAN DEFAULT 0 -""" - -# Activities table migrations -CREATE_ACTIVITIES_NEW_TABLE = """ - CREATE TABLE activities_new ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - description TEXT NOT NULL, - start_time TEXT NOT NULL, - end_time TEXT NOT NULL, - source_events TEXT, - version INTEGER DEFAULT 1, - created_at TEXT DEFAULT CURRENT_TIMESTAMP, - deleted BOOLEAN DEFAULT 0, - source_event_ids TEXT - ) -""" - -MIGRATE_ACTIVITIES_DATA = """ - INSERT INTO activities_new ( - id, title, description, start_time, end_time, source_events, - version, created_at, deleted, source_event_ids - ) - SELECT - id, COALESCE(title, SUBSTR(description, 1, 50)), description, - start_time, end_time, source_events, - COALESCE(version, 1), created_at, - COALESCE(deleted, 0), source_event_ids - FROM activities -""" - -DROP_OLD_ACTIVITIES_TABLE = "DROP TABLE activities" - -RENAME_ACTIVITIES_TABLE = "ALTER TABLE activities_new RENAME TO activities" - -ADD_ACTIVITIES_VERSION_COLUMN = """ - ALTER TABLE activities - ADD COLUMN version INTEGER DEFAULT 1 -""" - -ADD_ACTIVITIES_TITLE_COLUMN = """ - ALTER TABLE activities - ADD COLUMN title TEXT DEFAULT '' -""" - -UPDATE_ACTIVITIES_TITLE = """ - UPDATE activities - SET title = SUBSTR(description, 1, 50) - WHERE title = '' OR title IS NULL -""" - -ADD_ACTIVITIES_DELETED_COLUMN = """ - ALTER TABLE activities - ADD COLUMN deleted BOOLEAN DEFAULT 0 -""" - -ADD_ACTIVITIES_SOURCE_EVENT_IDS_COLUMN = """ - ALTER TABLE activities - ADD COLUMN source_event_ids TEXT DEFAULT NULL -""" - -UPDATE_ACTIVITIES_SOURCE_EVENT_IDS = """ - UPDATE activities - SET source_event_ids = source_events - WHERE source_event_ids IS NULL AND source_events IS NOT NULL -""" - -# LLM models table migrations -ADD_LLM_MODELS_LAST_TEST_STATUS_COLUMN = """ - ALTER TABLE llm_models ADD COLUMN last_test_status INTEGER DEFAULT 0 -""" - -ADD_LLM_MODELS_LAST_TESTED_AT_COLUMN = """ - ALTER TABLE llm_models ADD COLUMN last_tested_at TEXT -""" - -ADD_LLM_MODELS_LAST_TEST_ERROR_COLUMN = """ - ALTER TABLE llm_models ADD COLUMN last_test_error TEXT -""" - -# Messages table migrations -ADD_MESSAGES_IMAGES_COLUMN = """ - ALTER TABLE messages ADD COLUMN images TEXT -""" - -# Actions table migrations -ADD_ACTIONS_EXTRACT_KNOWLEDGE_COLUMN = """ - ALTER TABLE actions ADD COLUMN extract_knowledge BOOLEAN DEFAULT 0 -""" - -ADD_ACTIONS_KNOWLEDGE_EXTRACTED_COLUMN = """ - ALTER TABLE actions ADD COLUMN knowledge_extracted BOOLEAN DEFAULT 0 -""" - -# Knowledge table migrations -ADD_KNOWLEDGE_SOURCE_ACTION_ID_COLUMN = """ - ALTER TABLE knowledge ADD COLUMN source_action_id TEXT -""" diff --git a/backend/core/sqls/queries.py b/backend/core/sqls/queries.py index 7621903..d57004c 100644 --- a/backend/core/sqls/queries.py +++ b/backend/core/sqls/queries.py @@ -176,11 +176,11 @@ # Maintenance / cleanup queries DELETE_EVENT_IMAGES_BEFORE_TIMESTAMP = """ DELETE FROM event_images - WHERE event_id IN (SELECT id FROM events WHERE timestamp < ?) + WHERE event_id IN (SELECT id FROM events WHERE start_time < ?) """ DELETE_EVENTS_BEFORE_TIMESTAMP = """ - DELETE FROM events WHERE timestamp < ? + DELETE FROM events WHERE start_time < ? """ SOFT_DELETE_ACTIVITIES_BEFORE_START_TIME = """ @@ -301,3 +301,45 @@ # Pragma queries (for table inspection) PRAGMA_TABLE_INFO = "PRAGMA table_info({})" + +# ==================== Pomodoro Work Phases Queries ==================== + +INSERT_WORK_PHASE = """ + INSERT INTO pomodoro_work_phases ( + id, session_id, phase_number, status, + phase_start_time, phase_end_time, retry_count + ) VALUES (?, ?, ?, ?, ?, ?, ?) +""" + +SELECT_WORK_PHASES_BY_SESSION = """ + SELECT * FROM pomodoro_work_phases + WHERE session_id = ? + ORDER BY phase_number ASC +""" + +SELECT_WORK_PHASE_BY_SESSION_AND_NUMBER = """ + SELECT * FROM pomodoro_work_phases + WHERE session_id = ? AND phase_number = ? +""" + +UPDATE_WORK_PHASE_STATUS = """ + UPDATE pomodoro_work_phases + SET status = ?, processing_error = ?, retry_count = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? +""" + +UPDATE_WORK_PHASE_COMPLETED = """ + UPDATE pomodoro_work_phases + SET status = 'completed', activity_count = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? +""" + +INCREMENT_WORK_PHASE_RETRY = """ + UPDATE pomodoro_work_phases + SET retry_count = retry_count + 1, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + RETURNING retry_count +""" diff --git a/backend/core/sqls/schema.py b/backend/core/sqls/schema.py index 8f31740..c228e1d 100644 --- a/backend/core/sqls/schema.py +++ b/backend/core/sqls/schema.py @@ -39,6 +39,7 @@ source_action_id TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, deleted BOOLEAN DEFAULT 0, + favorite BOOLEAN DEFAULT 0, FOREIGN KEY (source_action_id) REFERENCES actions(id) ON DELETE SET NULL ) """ @@ -55,7 +56,9 @@ scheduled_date TEXT, scheduled_time TEXT, scheduled_end_time TEXT, - recurrence_rule TEXT + recurrence_rule TEXT, + expires_at TEXT, + source_type TEXT DEFAULT 'ai' ) """ @@ -80,8 +83,13 @@ session_duration_minutes INTEGER, topic_tags TEXT, source_event_ids TEXT, + source_action_ids TEXT, + aggregation_mode TEXT DEFAULT 'action_based' CHECK(aggregation_mode IN ('event_based', 'action_based')), user_merged_from_ids TEXT, user_split_into_ids TEXT, + pomodoro_session_id TEXT, + pomodoro_work_phase INTEGER, + focus_score REAL, deleted BOOLEAN DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP @@ -228,6 +236,60 @@ ) """ +CREATE_POMODORO_SESSIONS_TABLE = """ + CREATE TABLE IF NOT EXISTS pomodoro_sessions ( + id TEXT PRIMARY KEY, + user_intent TEXT NOT NULL, + planned_duration_minutes INTEGER DEFAULT 25, + actual_duration_minutes INTEGER, + start_time TEXT NOT NULL, + end_time TEXT, + status TEXT NOT NULL, + processing_status TEXT DEFAULT 'pending', + processing_started_at TEXT, + processing_completed_at TEXT, + processing_error TEXT, + llm_evaluation_result TEXT, + llm_evaluation_computed_at TEXT, + interruption_count INTEGER DEFAULT 0, + interruption_reasons TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + deleted BOOLEAN DEFAULT 0, + CHECK(status IN ('active', 'completed', 'abandoned')), + CHECK(processing_status IN ('pending', 'processing', 'completed', 'failed')) + ) +""" + +CREATE_POMODORO_WORK_PHASES_TABLE = """ + CREATE TABLE IF NOT EXISTS pomodoro_work_phases ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + phase_number INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + processing_error TEXT, + retry_count INTEGER DEFAULT 0, + phase_start_time TEXT NOT NULL, + phase_end_time TEXT, + activity_count INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES pomodoro_sessions(id) ON DELETE CASCADE, + CHECK(status IN ('pending', 'processing', 'completed', 'failed')), + UNIQUE(session_id, phase_number) + ) +""" + +CREATE_POMODORO_WORK_PHASES_SESSION_INDEX = """ + CREATE INDEX IF NOT EXISTS idx_work_phases_session + ON pomodoro_work_phases(session_id, phase_number) +""" + +CREATE_POMODORO_WORK_PHASES_STATUS_INDEX = """ + CREATE INDEX IF NOT EXISTS idx_work_phases_status + ON pomodoro_work_phases(status) +""" + CREATE_KNOWLEDGE_CREATED_INDEX = """ CREATE INDEX IF NOT EXISTS idx_knowledge_created ON knowledge(created_at DESC) @@ -243,6 +305,11 @@ ON knowledge(source_action_id) """ +CREATE_KNOWLEDGE_FAVORITE_INDEX = """ + CREATE INDEX IF NOT EXISTS idx_knowledge_favorite + ON knowledge(favorite) +""" + CREATE_TODOS_CREATED_INDEX = """ CREATE INDEX IF NOT EXISTS idx_todos_created ON todos(created_at DESC) @@ -386,6 +453,28 @@ ON session_preferences(confidence_score DESC) """ +# ============ Pomodoro Sessions Indexes ============ + +CREATE_POMODORO_SESSIONS_STATUS_INDEX = """ + CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_status + ON pomodoro_sessions(status) +""" + +CREATE_POMODORO_SESSIONS_PROCESSING_STATUS_INDEX = """ + CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_processing_status + ON pomodoro_sessions(processing_status) +""" + +CREATE_POMODORO_SESSIONS_START_TIME_INDEX = """ + CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_start_time + ON pomodoro_sessions(start_time DESC) +""" + +CREATE_POMODORO_SESSIONS_CREATED_INDEX = """ + CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_created + ON pomodoro_sessions(created_at DESC) +""" + # All table creation statements in order ALL_TABLES = [ CREATE_RAW_RECORDS_TABLE, @@ -405,6 +494,9 @@ CREATE_ACTIONS_TABLE, CREATE_ACTION_IMAGES_TABLE, CREATE_SESSION_PREFERENCES_TABLE, + # Pomodoro feature + CREATE_POMODORO_SESSIONS_TABLE, + CREATE_POMODORO_WORK_PHASES_TABLE, ] # All index creation statements @@ -416,10 +508,13 @@ CREATE_KNOWLEDGE_CREATED_INDEX, CREATE_KNOWLEDGE_DELETED_INDEX, CREATE_KNOWLEDGE_SOURCE_ACTION_INDEX, + CREATE_KNOWLEDGE_FAVORITE_INDEX, CREATE_TODOS_CREATED_INDEX, CREATE_TODOS_COMPLETED_INDEX, CREATE_TODOS_DELETED_INDEX, CREATE_DIARIES_DATE_INDEX, + CREATE_POMODORO_WORK_PHASES_SESSION_INDEX, + CREATE_POMODORO_WORK_PHASES_STATUS_INDEX, CREATE_LLM_USAGE_TIMESTAMP_INDEX, CREATE_LLM_USAGE_MODEL_INDEX, CREATE_LLM_USAGE_MODEL_CONFIG_ID_INDEX, @@ -441,4 +536,9 @@ CREATE_ACTION_IMAGES_HASH_INDEX, CREATE_SESSION_PREFERENCES_TYPE_INDEX, CREATE_SESSION_PREFERENCES_CONFIDENCE_INDEX, + # Pomodoro sessions indexes + CREATE_POMODORO_SESSIONS_STATUS_INDEX, + CREATE_POMODORO_SESSIONS_PROCESSING_STATUS_INDEX, + CREATE_POMODORO_SESSIONS_START_TIME_INDEX, + CREATE_POMODORO_SESSIONS_CREATED_INDEX, ] diff --git a/backend/handlers/__init__.py b/backend/handlers/__init__.py index 7f4dbcf..b9997bf 100644 --- a/backend/handlers/__init__.py +++ b/backend/handlers/__init__.py @@ -174,15 +174,15 @@ def register_fastapi_routes(app: "FastAPI", prefix: str = "/api") -> None: } if method == "GET": - app.get(**route_params)(func) # type: ignore + app.get(**route_params)(func) elif method == "POST": - app.post(**route_params)(func) # type: ignore + app.post(**route_params)(func) elif method == "PUT": - app.put(**route_params)(func) # type: ignore + app.put(**route_params)(func) elif method == "DELETE": - app.delete(**route_params)(func) # type: ignore + app.delete(**route_params)(func) elif method == "PATCH": - app.patch(**route_params)(func) # type: ignore + app.patch(**route_params)(func) else: logger.warning(f"Unknown HTTP method: {method} for {handler_name}") continue @@ -206,11 +206,18 @@ def register_fastapi_routes(app: "FastAPI", prefix: str = "/api") -> None: # ruff: noqa: E402 from . import ( activities, + activity_ratings, agents, chat, events, insights, + knowledge_merge, monitoring, + pomodoro, + pomodoro_goals, + pomodoro_linking, + pomodoro_presets, + pomodoro_stats, processing, resources, system, @@ -222,11 +229,15 @@ def register_fastapi_routes(app: "FastAPI", prefix: str = "/api") -> None: "register_fastapi_routes", "get_registered_handlers", "activities", + "activity_ratings", "agents", "chat", "events", "insights", "monitoring", + "pomodoro", + "pomodoro_presets", + "pomodoro_stats", "processing", "resources", "system", diff --git a/backend/handlers/activity_ratings.py b/backend/handlers/activity_ratings.py new file mode 100644 index 0000000..f1235ec --- /dev/null +++ b/backend/handlers/activity_ratings.py @@ -0,0 +1,211 @@ +""" +Activity Ratings Handler - API endpoints for multi-dimensional activity ratings + +Endpoints: +- POST /activities/rating/save - Save or update an activity rating +- POST /activities/rating/get - Get all ratings for an activity +- POST /activities/rating/delete - Delete a specific rating +""" + +from datetime import datetime +from typing import List, Optional + +from core.db import get_db +from core.logger import get_logger +from models.base import BaseModel +from models.responses import TimedOperationResponse + +# CRITICAL: Use relative import to avoid circular imports +from . import api_handler + +logger = get_logger(__name__) + + +# ============ Request Models ============ + + +class SaveActivityRatingRequest(BaseModel): + """Request to save or update an activity rating""" + + activity_id: str + dimension: str + rating: int # 1-5 + note: Optional[str] = None + + +class GetActivityRatingsRequest(BaseModel): + """Request to get ratings for an activity""" + + activity_id: str + + +class DeleteActivityRatingRequest(BaseModel): + """Request to delete a specific rating""" + + activity_id: str + dimension: str + + +# ============ Response Models ============ + + +class ActivityRatingData(BaseModel): + """Individual rating record""" + + id: str + activity_id: str + dimension: str + rating: int + note: Optional[str] = None + created_at: str + updated_at: str + + +class SaveActivityRatingResponse(TimedOperationResponse): + """Response after saving a rating""" + + data: Optional[ActivityRatingData] = None + + +class GetActivityRatingsResponse(TimedOperationResponse): + """Response with list of ratings""" + + data: Optional[List[ActivityRatingData]] = None + + +# ============ API Handlers ============ + + +@api_handler( + body=SaveActivityRatingRequest, + method="POST", + path="/activities/rating/save", + tags=["activities"], +) +async def save_activity_rating( + body: SaveActivityRatingRequest, +) -> SaveActivityRatingResponse: + """ + Save or update an activity rating + + Supports multi-dimensional ratings: + - focus_level: How focused were you? (1-5) + - productivity: How productive was this session? (1-5) + - importance: How important was this activity? (1-5) + - satisfaction: How satisfied are you with the outcome? (1-5) + """ + try: + db = get_db() + + # Validate rating range + if not 1 <= body.rating <= 5: + return SaveActivityRatingResponse( + success=False, + message="Rating must be between 1 and 5", + timestamp=datetime.now().isoformat(), + ) + + # Save rating + rating_record = await db.activity_ratings.save_rating( + activity_id=body.activity_id, + dimension=body.dimension, + rating=body.rating, + note=body.note, + ) + + logger.info( + f"Saved activity rating: {body.activity_id} - " + f"{body.dimension} = {body.rating}" + ) + + return SaveActivityRatingResponse( + success=True, + message="Rating saved successfully", + data=ActivityRatingData(**rating_record), + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to save activity rating: {e}", exc_info=True) + return SaveActivityRatingResponse( + success=False, + message=f"Failed to save rating: {str(e)}", + timestamp=datetime.now().isoformat(), + ) + + +@api_handler( + body=GetActivityRatingsRequest, + method="POST", + path="/activities/rating/get", + tags=["activities"], +) +async def get_activity_ratings( + body: GetActivityRatingsRequest, +) -> GetActivityRatingsResponse: + """ + Get all ratings for an activity + + Returns ratings for all dimensions that have been rated. + """ + try: + db = get_db() + + # Fetch ratings + ratings = await db.activity_ratings.get_ratings_by_activity(body.activity_id) + + logger.debug(f"Retrieved {len(ratings)} ratings for activity {body.activity_id}") + + return GetActivityRatingsResponse( + success=True, + message=f"Retrieved {len(ratings)} rating(s)", + data=[ActivityRatingData(**r) for r in ratings], + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to get activity ratings: {e}", exc_info=True) + return GetActivityRatingsResponse( + success=False, + message=f"Failed to get ratings: {str(e)}", + timestamp=datetime.now().isoformat(), + ) + + +@api_handler( + body=DeleteActivityRatingRequest, + method="POST", + path="/activities/rating/delete", + tags=["activities"], +) +async def delete_activity_rating( + body: DeleteActivityRatingRequest, +) -> TimedOperationResponse: + """ + Delete a specific activity rating + + Removes the rating for a specific dimension. + """ + try: + db = get_db() + + # Delete rating + await db.activity_ratings.delete_rating(body.activity_id, body.dimension) + + logger.info( + f"Deleted activity rating: {body.activity_id} - {body.dimension}" + ) + + return TimedOperationResponse( + success=True, + message="Rating deleted successfully", + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to delete activity rating: {e}", exc_info=True) + return TimedOperationResponse( + success=False, + message=f"Failed to delete rating: {str(e)}", + timestamp=datetime.now().isoformat(), + ) diff --git a/backend/handlers/events.py b/backend/handlers/events.py index fe37413..c4a5212 100644 --- a/backend/handlers/events.py +++ b/backend/handlers/events.py @@ -358,3 +358,77 @@ async def delete_event(body: DeleteEventRequest) -> TimedOperationResponse: data={"deleted": True, "eventId": body.event_id}, timestamp=datetime.now().isoformat(), ) + + +@api_handler( + body=GetActionsByEventRequest, # Reuse the same request model + method="POST", + path="/activities/get-actions", + tags=["activities"], +) +async def get_actions_by_activity( + body: GetActionsByEventRequest, +) -> GetActionsByEventResponse: + """ + Get all actions for a specific activity (action-based aggregation drill-down). + + Args: + body: Request containing event_id (but we'll use it as activity_id) + + Returns: + Response with list of actions including screenshots + """ + try: + db = get_db() + + # Note: Reusing GetActionsByEventRequest, so field is event_id but we treat it as activity_id + activity_id = body.event_id + + # Get the activity to find source action IDs + activity = await db.activities.get_by_id(activity_id) + if not activity: + return GetActionsByEventResponse( + success=False, actions=[], error="Activity not found" + ) + + # Get source action IDs (action-based aggregation) + source_action_ids = activity.get("source_action_ids", []) + if not source_action_ids: + # Fallback to event-based if activity is old format + source_event_ids = activity.get("source_event_ids", []) + if source_event_ids: + # Get actions from events (backward compatibility) + all_action_ids = [] + for event_id in source_event_ids: + event = await db.events.get_by_id(event_id) + if event: + all_action_ids.extend(event.get("source_action_ids", [])) + source_action_ids = all_action_ids + + if not source_action_ids: + return GetActionsByEventResponse(success=True, actions=[]) + + # Get actions by IDs (this will automatically load screenshots) + action_dicts = await db.actions.get_by_ids(source_action_ids) + + # Convert to ActionResponse objects + actions = [ + ActionResponse( + id=a["id"], + title=a["title"], + description=a["description"], + keywords=a.get("keywords", []), + timestamp=a["timestamp"], + screenshots=a.get("screenshots", []), + created_at=a["created_at"], + ) + for a in action_dicts + ] + + return GetActionsByEventResponse(success=True, actions=actions) + + except Exception as e: + logger.error(f"Failed to get actions by activity: {e}", exc_info=True) + return GetActionsByEventResponse( + success=False, actions=[], error=str(e) + ) diff --git a/backend/handlers/insights.py b/backend/handlers/insights.py index 38b83f4..a5f9964 100644 --- a/backend/handlers/insights.py +++ b/backend/handlers/insights.py @@ -11,20 +11,30 @@ from core.db import get_db from core.logger import get_logger from models.requests import ( + CreateKnowledgeRequest, + CreateTodoRequest, DeleteItemRequest, GenerateDiaryRequest, GetDiaryListRequest, GetRecentEventsRequest, GetTodoListRequest, ScheduleTodoRequest, + ToggleKnowledgeFavoriteRequest, UnscheduleTodoRequest, + UpdateKnowledgeRequest, ) from models.responses import ( + CreateKnowledgeResponse, + CreateTodoResponse, DeleteDiaryResponse, DiaryData, DiaryListData, GenerateDiaryResponse, GetDiaryListResponse, + KnowledgeData, + TodoData, + ToggleKnowledgeFavoriteResponse, + UpdateKnowledgeResponse, ) from perception.image_manager import get_image_manager @@ -33,6 +43,17 @@ logger = get_logger(__name__) +async def _check_knowledge_merge_lock() -> None: + """Check if knowledge analysis is in progress and raise error if so""" + from services.knowledge_merger import KnowledgeMerger + + if KnowledgeMerger.is_locked(): + raise RuntimeError( + "Cannot modify knowledge while analysis is in progress. " + "Please wait for the analysis to complete or cancel it." + ) + + def get_pipeline(): """Get new architecture processing pipeline instance""" coordinator = get_coordinator() @@ -194,6 +215,9 @@ async def delete_knowledge(body: DeleteItemRequest) -> Dict[str, Any]: @returns Deletion result """ try: + # Check if analysis is in progress + await _check_knowledge_merge_lock() + db, _ = _get_data_access() await db.knowledge.delete(body.id) @@ -212,6 +236,192 @@ async def delete_knowledge(body: DeleteItemRequest) -> Dict[str, Any]: } +@api_handler( + body=ToggleKnowledgeFavoriteRequest, + method="POST", + path="/insights/toggle-knowledge-favorite", + tags=["insights"], + summary="Toggle knowledge favorite status", + description="Toggle the favorite status of a knowledge item", +) +async def toggle_knowledge_favorite(body: ToggleKnowledgeFavoriteRequest) -> ToggleKnowledgeFavoriteResponse: + """Toggle knowledge favorite status + + @param body - Contains knowledge ID + @returns Updated knowledge data with new favorite status + """ + try: + # Check if analysis is in progress + await _check_knowledge_merge_lock() + + db, _ = _get_data_access() + new_favorite = await db.knowledge.toggle_favorite(body.id) + + if new_favorite is None: + return ToggleKnowledgeFavoriteResponse( + success=False, + message="Knowledge not found", + timestamp=datetime.now().isoformat(), + ) + + # Get updated knowledge data + knowledge_list = await db.knowledge.get_list() + knowledge_item = next((k for k in knowledge_list if k["id"] == body.id), None) + + if knowledge_item: + knowledge_data = KnowledgeData(**knowledge_item) + return ToggleKnowledgeFavoriteResponse( + success=True, + data=knowledge_data, + message=f"Knowledge {'favorited' if new_favorite else 'unfavorited'}", + timestamp=datetime.now().isoformat(), + ) + else: + return ToggleKnowledgeFavoriteResponse( + success=False, + message="Failed to retrieve updated knowledge", + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to toggle knowledge favorite: {e}", exc_info=True) + return ToggleKnowledgeFavoriteResponse( + success=False, + message=f"Failed to toggle knowledge favorite: {str(e)}", + timestamp=datetime.now().isoformat(), + ) + + +@api_handler( + body=CreateKnowledgeRequest, + method="POST", + path="/insights/create-knowledge", + tags=["insights"], + summary="Create knowledge manually", + description="Create a new knowledge item manually", +) +async def create_knowledge(body: CreateKnowledgeRequest) -> CreateKnowledgeResponse: + """Create knowledge manually + + @param body - Contains title, description, and keywords + @returns Created knowledge data + """ + try: + # Check if analysis is in progress + await _check_knowledge_merge_lock() + + db, _ = _get_data_access() + + # Generate unique ID + knowledge_id = str(uuid.uuid4()) + created_at = datetime.now().isoformat() + + # Save knowledge + await db.knowledge.save( + knowledge_id=knowledge_id, + title=body.title, + description=body.description, + keywords=body.keywords, + created_at=created_at, + source_action_id=None, # Manual creation has no source action + favorite=False, + ) + + # Return created knowledge + knowledge_data = KnowledgeData( + id=knowledge_id, + title=body.title, + description=body.description, + keywords=body.keywords, + created_at=created_at, + source_action_id=None, + favorite=False, + deleted=False, + ) + + return CreateKnowledgeResponse( + success=True, + data=knowledge_data, + message="Knowledge created successfully", + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to create knowledge: {e}", exc_info=True) + return CreateKnowledgeResponse( + success=False, + message=f"Failed to create knowledge: {str(e)}", + timestamp=datetime.now().isoformat(), + ) + + +@api_handler( + body=UpdateKnowledgeRequest, + method="POST", + path="/insights/update-knowledge", + tags=["insights"], + summary="Update knowledge", + description="Update an existing knowledge item", +) +async def update_knowledge(body: UpdateKnowledgeRequest) -> UpdateKnowledgeResponse: + """Update knowledge + + @param body - Contains knowledge ID, title, description, and keywords + @returns Updated knowledge data + """ + try: + # Check if analysis is in progress + await _check_knowledge_merge_lock() + + db, _ = _get_data_access() + + # Check if knowledge exists + knowledge_list = await db.knowledge.get_list() + knowledge_item = next((k for k in knowledge_list if k["id"] == body.id), None) + + if not knowledge_item: + return UpdateKnowledgeResponse( + success=False, + message="Knowledge not found", + timestamp=datetime.now().isoformat(), + ) + + # Update knowledge + await db.knowledge.update( + knowledge_id=body.id, + title=body.title, + description=body.description, + keywords=body.keywords, + ) + + # Get updated knowledge + knowledge_list = await db.knowledge.get_list() + updated_knowledge = next((k for k in knowledge_list if k["id"] == body.id), None) + + if updated_knowledge: + knowledge_data = KnowledgeData(**updated_knowledge) + return UpdateKnowledgeResponse( + success=True, + data=knowledge_data, + message="Knowledge updated successfully", + timestamp=datetime.now().isoformat(), + ) + else: + return UpdateKnowledgeResponse( + success=False, + message="Failed to retrieve updated knowledge", + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to update knowledge: {e}", exc_info=True) + return UpdateKnowledgeResponse( + success=False, + message=f"Failed to update knowledge: {str(e)}", + timestamp=datetime.now().isoformat(), + ) + + # ============ Todo Related Interfaces ============ @@ -252,6 +462,39 @@ async def get_todo_list(body: GetTodoListRequest) -> Dict[str, Any]: } +@api_handler( + body=DeleteItemRequest, + method="POST", + path="/insights/complete-todo", + tags=["insights"], + summary="Complete todo", + description="Mark specified todo as completed", +) +async def complete_todo(body: DeleteItemRequest) -> Dict[str, Any]: + """Complete todo (mark as completed) + + @param body - Contains todo ID to complete + @returns Completion result + """ + try: + db, _ = _get_data_access() + await db.todos.complete(body.id) + + return { + "success": True, + "message": "Todo completed", + "timestamp": datetime.now().isoformat(), + } + + except Exception as e: + logger.error(f"Failed to complete todo: {e}", exc_info=True) + return { + "success": False, + "message": f"Failed to complete todo: {str(e)}", + "timestamp": datetime.now().isoformat(), + } + + @api_handler( body=DeleteItemRequest, method="POST", @@ -647,3 +890,110 @@ async def get_knowledge_count_by_date() -> Dict[str, Any]: "message": f"Failed to get knowledge count by date: {str(e)}", "timestamp": datetime.now().isoformat(), } + + +# ============ Manual Todo Creation ============ + + +@api_handler( + body=CreateTodoRequest, + method="POST", + path="/insights/create-todo", + tags=["insights"], + summary="Create todo manually", + description="Create a new todo manually (no expiration, source_type='manual')", +) +async def create_todo(body: CreateTodoRequest) -> CreateTodoResponse: + """Create a todo manually + + Manually created todos have no expiration time and source_type='manual'. + They will persist until explicitly deleted or completed. + + @param body - Contains title, description, keywords, and optional scheduling info + @returns Created todo data + """ + try: + db, _ = _get_data_access() + + # Generate unique ID + todo_id = str(uuid.uuid4()) + created_at = datetime.now().isoformat() + + # Save todo manually (source_type='manual', no expiration) + await db.todos.save( + todo_id=todo_id, + title=body.title, + description=body.description, + keywords=body.keywords, + created_at=created_at, + completed=False, + scheduled_date=body.scheduled_date, + scheduled_time=body.scheduled_time, + scheduled_end_time=body.scheduled_end_time, + source_type="manual", + ) + + # Get the saved todo + saved_todo = await db.todos.get_by_id(todo_id) + + if saved_todo: + todo_data = TodoData(**saved_todo) + return CreateTodoResponse( + success=True, + data=todo_data, + message="Todo created successfully", + timestamp=datetime.now().isoformat(), + ) + else: + return CreateTodoResponse( + success=False, + message="Failed to retrieve created todo", + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to create todo: {e}", exc_info=True) + return CreateTodoResponse( + success=False, + message=f"Failed to create todo: {str(e)}", + timestamp=datetime.now().isoformat(), + ) + + +@api_handler( + method="POST", + path="/insights/cleanup-expired-todos", + tags=["insights"], + summary="Clean up expired todos", + description="Soft delete all expired AI-generated todos", +) +async def cleanup_expired_todos() -> Dict[str, Any]: + """Clean up expired AI-generated todos + + Soft deletes todos that: + - Are AI-generated (source_type='ai') + - Have an expires_at timestamp in the past + - Are not already deleted + - Are not completed + + @returns Cleanup result with count of deleted todos + """ + try: + db, _ = _get_data_access() + + deleted_count = await db.todos.delete_expired() + + return { + "success": True, + "message": f"Cleaned up {deleted_count} expired todos", + "data": {"deleted_count": deleted_count}, + "timestamp": datetime.now().isoformat(), + } + + except Exception as e: + logger.error(f"Failed to cleanup expired todos: {e}", exc_info=True) + return { + "success": False, + "message": f"Failed to cleanup expired todos: {str(e)}", + "timestamp": datetime.now().isoformat(), + } diff --git a/backend/handlers/knowledge_merge.py b/backend/handlers/knowledge_merge.py new file mode 100644 index 0000000..6bfc94c --- /dev/null +++ b/backend/handlers/knowledge_merge.py @@ -0,0 +1,217 @@ +""" +Knowledge merge handlers for analyzing and merging similar knowledge entries. +""" + +from datetime import datetime +from typing import Any, Dict, cast + +from core.db import get_db +from core.logger import get_logger +from core.protocols import KnowledgeRepositoryProtocol +from llm.manager import get_llm_manager +from llm.prompt_manager import PromptManager +from models.requests import ( + AnalyzeKnowledgeMergeRequest, + ExecuteKnowledgeMergeRequest, + MergeGroup, +) +from models.responses import ( + AnalyzeKnowledgeMergeResponse, + ExecuteKnowledgeMergeResponse, + MergeSuggestion, + MergeResult, +) +from services.knowledge_merger import KnowledgeMerger + +# CRITICAL: Use relative import to avoid circular imports +from . import api_handler + +logger = get_logger(__name__) + + +@api_handler( + method="GET", + path="/knowledge/analysis-status", + tags=["knowledge"], + summary="Get knowledge analysis status", + description="Check if knowledge analysis is currently in progress", +) +async def get_analysis_status() -> Dict[str, Any]: + """Get current knowledge analysis status""" + try: + is_locked = KnowledgeMerger.is_locked() + return { + "success": True, + "data": { + "is_analyzing": is_locked, + }, + "timestamp": datetime.now().isoformat(), + } + except Exception as e: + logger.error(f"Failed to get analysis status: {e}", exc_info=True) + return { + "success": False, + "message": f"Failed to get analysis status: {str(e)}", + "timestamp": datetime.now().isoformat(), + } + + +@api_handler( + body=AnalyzeKnowledgeMergeRequest, + method="POST", + path="/knowledge/analyze-merge", + tags=["knowledge"], +) +async def analyze_knowledge_merge( + body: AnalyzeKnowledgeMergeRequest, +) -> AnalyzeKnowledgeMergeResponse: + """ + Analyze knowledge entries for similarity and generate merge suggestions. + Uses LLM to detect similar content and propose merges. + """ + try: + db = get_db() + knowledge_repo = cast(KnowledgeRepositoryProtocol, db.knowledge) + llm_manager = get_llm_manager() + prompt_manager = PromptManager() + + merger = KnowledgeMerger(knowledge_repo, prompt_manager, llm_manager) + + # Analyze similarities + suggestions_data, total_tokens = await merger.analyze_similarities( + filter_by_keyword=body.filter_by_keyword, + include_favorites=body.include_favorites, + similarity_threshold=body.similarity_threshold, + ) + + # Convert to response models + suggestions = [ + MergeSuggestion( + group_id=s.group_id, + knowledge_ids=s.knowledge_ids, + merged_title=s.merged_title, + merged_description=s.merged_description, + merged_keywords=s.merged_keywords, + similarity_score=s.similarity_score, + merge_reason=s.merge_reason, + estimated_tokens=s.estimated_tokens, + ) + for s in suggestions_data + ] + + # Calculate analyzed count + knowledge_list = await merger._fetch_knowledge( + body.filter_by_keyword, body.include_favorites + ) + + logger.info( + f"Analyzed {len(knowledge_list)} knowledge entries, " + f"found {len(suggestions)} merge suggestions, " + f"used {total_tokens} tokens" + ) + + return AnalyzeKnowledgeMergeResponse( + success=True, + message=f"Found {len(suggestions)} merge suggestions", + timestamp=datetime.now().isoformat(), + suggestions=suggestions, + total_estimated_tokens=total_tokens, + analyzed_count=len(knowledge_list), + suggested_merge_count=len(suggestions), + ) + + except Exception as e: + logger.error(f"Failed to analyze knowledge merge: {e}", exc_info=True) + return AnalyzeKnowledgeMergeResponse( + success=False, + message="Failed to analyze knowledge merge", + error=str(e), + timestamp=datetime.now().isoformat(), + suggestions=[], + total_estimated_tokens=0, + analyzed_count=0, + suggested_merge_count=0, + ) + + +@api_handler( + body=ExecuteKnowledgeMergeRequest, + method="POST", + path="/knowledge/execute-merge", + tags=["knowledge"], +) +async def execute_knowledge_merge( + body: ExecuteKnowledgeMergeRequest, +) -> ExecuteKnowledgeMergeResponse: + """ + Execute approved knowledge merge operations. + Creates merged knowledge entries and soft-deletes sources. + """ + try: + db = get_db() + knowledge_repo = cast(KnowledgeRepositoryProtocol, db.knowledge) + llm_manager = get_llm_manager() + prompt_manager = PromptManager() + + merger = KnowledgeMerger(knowledge_repo, prompt_manager, llm_manager) + + # Convert request models to service models + merge_groups = [] + for group in body.merge_groups: + from services.knowledge_merger import MergeGroup as ServiceMergeGroup + + merge_groups.append( + ServiceMergeGroup( + group_id=group.group_id, + knowledge_ids=group.knowledge_ids, + merged_title=group.merged_title, + merged_description=group.merged_description, + merged_keywords=group.merged_keywords, + merge_reason=group.merge_reason, + keep_favorite=group.keep_favorite, + ) + ) + + # Execute merge + results_data = await merger.execute_merge(merge_groups) + + # Convert to response models + results = [ + MergeResult( + group_id=r.group_id, + merged_knowledge_id=r.merged_knowledge_id, + deleted_knowledge_ids=r.deleted_knowledge_ids, + success=r.success, + error=r.error, + ) + for r in results_data + ] + + total_merged = sum(1 for r in results if r.success) + total_deleted = sum(len(r.deleted_knowledge_ids) for r in results if r.success) + + logger.info( + f"Executed merge: {total_merged}/{len(results)} groups successful, " + f"{total_deleted} knowledge entries deleted" + ) + + return ExecuteKnowledgeMergeResponse( + success=True, + message=f"Successfully merged {total_merged} groups", + timestamp=datetime.now().isoformat(), + results=results, + total_merged=total_merged, + total_deleted=total_deleted, + ) + + except Exception as e: + logger.error(f"Failed to execute knowledge merge: {e}", exc_info=True) + return ExecuteKnowledgeMergeResponse( + success=False, + message="Failed to execute knowledge merge", + error=str(e), + timestamp=datetime.now().isoformat(), + results=[], + total_merged=0, + total_deleted=0, + ) diff --git a/backend/handlers/monitoring.py b/backend/handlers/monitoring.py index 7fbe9ef..e18e33f 100644 --- a/backend/handlers/monitoring.py +++ b/backend/handlers/monitoring.py @@ -247,15 +247,27 @@ async def get_monitors_auto_refresh_status() -> Dict[str, Any]: async def get_screen_settings() -> Dict[str, Any]: """Get screen capture settings. - Returns current screen capture settings from config. + Returns current screen capture settings from database. """ settings = get_settings() - screens = settings.get("screenshot.screen_settings", []) or [] - return { - "success": True, - "data": {"screens": screens, "count": len(screens)}, - "timestamp": datetime.now().isoformat(), - } + + try: + screens = settings.get_screenshot_screen_settings() + logger.debug(f"✓ Loaded {len(screens)} screen settings from database") + + return { + "success": True, + "data": {"screens": screens, "count": len(screens)}, + "timestamp": datetime.now().isoformat(), + } + except Exception as e: + logger.error(f"Failed to load screen settings: {e}") + return { + "success": False, + "message": f"Failed to load screen settings: {str(e)}", + "data": {"screens": [], "count": 0}, + "timestamp": datetime.now().isoformat(), + } @api_handler() diff --git a/backend/handlers/pomodoro.py b/backend/handlers/pomodoro.py new file mode 100644 index 0000000..28ca507 --- /dev/null +++ b/backend/handlers/pomodoro.py @@ -0,0 +1,504 @@ +""" +Pomodoro timer API handlers + +Endpoints: +- POST /pomodoro/start - Start a Pomodoro session +- POST /pomodoro/end - End current Pomodoro session +- GET /pomodoro/status - Get current Pomodoro session status +- POST /pomodoro/retry-work-phase - Manually retry work phase activity aggregation +""" + +import asyncio +from datetime import datetime, timedelta +from typing import Optional + +from core.coordinator import get_coordinator +from core.db import get_db +from core.logger import get_logger +from models.base import BaseModel +from models.responses import ( + EndPomodoroData, + EndPomodoroResponse, + GetPomodoroStatusResponse, + PomodoroSessionData, + StartPomodoroResponse, + TimedOperationResponse, + WorkPhaseInfo, + GetSessionPhasesResponse, +) + +from . import api_handler + +logger = get_logger(__name__) + + +class StartPomodoroRequest(BaseModel): + """Start Pomodoro request with rounds configuration""" + + user_intent: str + duration_minutes: int = 25 # Legacy field, calculated from rounds + associated_todo_id: Optional[str] = None # Optional TODO association + work_duration_minutes: int = 25 # Duration of work phase + break_duration_minutes: int = 5 # Duration of break phase + total_rounds: int = 4 # Number of work rounds + + +class EndPomodoroRequest(BaseModel): + """End Pomodoro request""" + + status: str = "completed" # completed, abandoned, interrupted + + +class RetryWorkPhaseRequest(BaseModel): + """Retry work phase activity aggregation request""" + + session_id: str + work_phase: int + + +class GetSessionPhasesRequest(BaseModel): + """Get session phases request""" + + session_id: str + + +class RetryLLMEvaluationRequest(BaseModel): + """Retry LLM evaluation request""" + + session_id: str + + +@api_handler( + body=StartPomodoroRequest, + method="POST", + path="/pomodoro/start", + tags=["pomodoro"], +) +async def start_pomodoro(body: StartPomodoroRequest) -> StartPomodoroResponse: + """ + Start a new Pomodoro session + + Args: + body: Request containing user_intent and duration_minutes + + Returns: + StartPomodoroResponse with session data + + Raises: + ValueError: If a Pomodoro session is already active or previous session is still processing + """ + try: + coordinator = get_coordinator() + + if not coordinator.pomodoro_manager: + return StartPomodoroResponse( + success=False, + message="Pomodoro manager not initialized", + error="Pomodoro manager not initialized", + timestamp=datetime.now().isoformat(), + ) + + # Start Pomodoro session + session_id = await coordinator.pomodoro_manager.start_pomodoro( + user_intent=body.user_intent, + duration_minutes=body.duration_minutes, + associated_todo_id=body.associated_todo_id, + work_duration_minutes=body.work_duration_minutes, + break_duration_minutes=body.break_duration_minutes, + total_rounds=body.total_rounds, + ) + + # Get session info + session_info = await coordinator.pomodoro_manager.get_current_session_info() + + if not session_info: + return StartPomodoroResponse( + success=False, + message="Failed to retrieve session info", + error="Failed to retrieve session info after starting", + timestamp=datetime.now().isoformat(), + ) + + logger.info( + f"Pomodoro session started via API: {session_id}, intent='{body.user_intent}'" + ) + + return StartPomodoroResponse( + success=True, + message="Pomodoro session started successfully", + data=PomodoroSessionData( + session_id=session_info["session_id"], + user_intent=session_info["user_intent"], + start_time=session_info["start_time"], + elapsed_minutes=session_info["elapsed_minutes"], + planned_duration_minutes=session_info["planned_duration_minutes"], + associated_todo_id=session_info.get("associated_todo_id"), + associated_todo_title=session_info.get("associated_todo_title"), + work_duration_minutes=session_info.get("work_duration_minutes", 25), + break_duration_minutes=session_info.get("break_duration_minutes", 5), + total_rounds=session_info.get("total_rounds", 4), + current_round=session_info.get("current_round", 1), + current_phase=session_info.get("current_phase", "work"), + phase_start_time=session_info.get("phase_start_time"), + completed_rounds=session_info.get("completed_rounds", 0), + remaining_phase_seconds=session_info.get("remaining_phase_seconds"), + ), + timestamp=datetime.now().isoformat(), + ) + + except ValueError as e: + # Expected errors (session already active, previous processing) + logger.warning(f"Failed to start Pomodoro session: {e}") + return StartPomodoroResponse( + success=False, + message=str(e), + error=str(e), + timestamp=datetime.now().isoformat(), + ) + except Exception as e: + logger.error(f"Unexpected error starting Pomodoro session: {e}", exc_info=True) + return StartPomodoroResponse( + success=False, + message="Failed to start Pomodoro session", + error=str(e), + timestamp=datetime.now().isoformat(), + ) + + +@api_handler( + body=EndPomodoroRequest, + method="POST", + path="/pomodoro/end", + tags=["pomodoro"], +) +async def end_pomodoro(body: EndPomodoroRequest) -> EndPomodoroResponse: + """ + End current Pomodoro session + + Args: + body: Request containing status (completed/abandoned/interrupted) + + Returns: + EndPomodoroResponse with processing job info + + Raises: + ValueError: If no active Pomodoro session + """ + try: + coordinator = get_coordinator() + + if not coordinator.pomodoro_manager: + return EndPomodoroResponse( + success=False, + message="Pomodoro manager not initialized", + error="Pomodoro manager not initialized", + timestamp=datetime.now().isoformat(), + ) + + # End Pomodoro session + result = await coordinator.pomodoro_manager.end_pomodoro(status=body.status) + + logger.info( + f"Pomodoro session ended via API: {result['session_id']}, status={body.status}" + ) + + return EndPomodoroResponse( + success=True, + message="Pomodoro session ended successfully", + data=EndPomodoroData( + session_id=result["session_id"], + status=result["status"], # ✅ Use new field + actual_work_minutes=result["actual_work_minutes"], # ✅ Use new field + raw_records_count=result.get("raw_records_count", 0), # ✅ Safe access + processing_job_id=None, # ✅ Deprecated, always None now + message=result.get("message", ""), + ), + timestamp=datetime.now().isoformat(), + ) + + except ValueError as e: + # Expected error (no active session) + logger.warning(f"Failed to end Pomodoro session: {e}") + return EndPomodoroResponse( + success=False, + message=str(e), + error=str(e), + timestamp=datetime.now().isoformat(), + ) + except Exception as e: + logger.error(f"Unexpected error ending Pomodoro session: {e}", exc_info=True) + return EndPomodoroResponse( + success=False, + message="Failed to end Pomodoro session", + error=str(e), + timestamp=datetime.now().isoformat(), + ) + + +@api_handler(method="GET", path="/pomodoro/status", tags=["pomodoro"]) +async def get_pomodoro_status() -> GetPomodoroStatusResponse: + """ + Get current Pomodoro session status + + Returns: + GetPomodoroStatusResponse with current session info or None if no active session + """ + try: + coordinator = get_coordinator() + + if not coordinator.pomodoro_manager: + return GetPomodoroStatusResponse( + success=False, + message="Pomodoro manager not initialized", + error="Pomodoro manager not initialized", + timestamp=datetime.now().isoformat(), + ) + + # Get current session info + session_info = await coordinator.pomodoro_manager.get_current_session_info() + + if not session_info: + # No active session + return GetPomodoroStatusResponse( + success=True, + message="No active Pomodoro session", + data=None, + timestamp=datetime.now().isoformat(), + ) + + return GetPomodoroStatusResponse( + success=True, + message="Active Pomodoro session found", + data=PomodoroSessionData( + session_id=session_info["session_id"], + user_intent=session_info["user_intent"], + start_time=session_info["start_time"], + elapsed_minutes=session_info["elapsed_minutes"], + planned_duration_minutes=session_info["planned_duration_minutes"], + # Add all missing fields for complete session state + associated_todo_id=session_info.get("associated_todo_id"), + associated_todo_title=session_info.get("associated_todo_title"), + work_duration_minutes=session_info.get("work_duration_minutes", 25), + break_duration_minutes=session_info.get("break_duration_minutes", 5), + total_rounds=session_info.get("total_rounds", 4), + current_round=session_info.get("current_round", 1), + current_phase=session_info.get("current_phase", "work"), + phase_start_time=session_info.get("phase_start_time"), + completed_rounds=session_info.get("completed_rounds", 0), + remaining_phase_seconds=session_info.get("remaining_phase_seconds"), + ), + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error( + f"Unexpected error getting Pomodoro status: {e}", exc_info=True + ) + return GetPomodoroStatusResponse( + success=False, + message="Failed to get Pomodoro status", + error=str(e), + timestamp=datetime.now().isoformat(), + ) + + +@api_handler( + body=RetryWorkPhaseRequest, + method="POST", + path="/pomodoro/retry-work-phase", + tags=["pomodoro"], +) +async def retry_work_phase_aggregation( + body: RetryWorkPhaseRequest, +) -> EndPomodoroResponse: + """ + Manually trigger work phase activity aggregation (for retry) + + This endpoint allows users to manually retry activity aggregation for a specific + work phase if the automatic aggregation failed or was incomplete. + + Args: + body: Request containing session_id and work_phase number + + Returns: + EndPomodoroResponse with success status and processing details + """ + try: + coordinator = get_coordinator() + + if not coordinator.pomodoro_manager: + return EndPomodoroResponse( + success=False, + message="Pomodoro manager not initialized", + error="Pomodoro manager not initialized", + timestamp=datetime.now().isoformat(), + ) + + # Get session from database + db = get_db() + session = await db.pomodoro_sessions.get_by_id(body.session_id) + if not session: + return EndPomodoroResponse( + success=False, + message=f"Session {body.session_id} not found", + error=f"Session {body.session_id} not found", + timestamp=datetime.now().isoformat(), + ) + + # Validate work_phase number + total_rounds = session.get("total_rounds", 4) + if body.work_phase < 1 or body.work_phase > total_rounds: + return EndPomodoroResponse( + success=False, + message=f"Invalid work phase {body.work_phase}. Must be between 1 and {total_rounds}", + error="Invalid work phase number", + timestamp=datetime.now().isoformat(), + ) + + # Calculate phase time range based on work_phase + # Note: This is a simplified calculation. For precise timing, + # we would need to store each phase's start/end time in database. + session_start = datetime.fromisoformat(session["start_time"]) + work_duration = session.get("work_duration_minutes", 25) + break_duration = session.get("break_duration_minutes", 5) + + # Calculate phase start time + # Each complete round = work + break + # For work_phase N: start = session_start + (N-1) * (work + break) + phase_start_offset = (body.work_phase - 1) * (work_duration + break_duration) + phase_start_time = session_start + timedelta(minutes=phase_start_offset) + + # Phase end time = start + work_duration + phase_end_time = phase_start_time + timedelta(minutes=work_duration) + + # Use session end time if this was the last work phase + if session.get("end_time"): + session_end = datetime.fromisoformat(session["end_time"]) + if phase_end_time > session_end: + phase_end_time = session_end + + logger.info( + f"Manually triggering work phase aggregation: " + f"session={body.session_id}, phase={body.work_phase}, " + f"time_range={phase_start_time.isoformat()} to {phase_end_time.isoformat()}" + ) + + # Trigger aggregation in background (non-blocking) + asyncio.create_task( + coordinator.pomodoro_manager._aggregate_work_phase_activities( + session_id=body.session_id, + work_phase=body.work_phase, + phase_start_time=phase_start_time, + phase_end_time=phase_end_time, + ) + ) + + return EndPomodoroResponse( + success=True, + message=f"Work phase {body.work_phase} aggregation triggered successfully", + data=EndPomodoroData( + session_id=body.session_id, + status="processing", # Work phase being retried + actual_work_minutes=0, # Not applicable for retry + processing_job_id=None, # Background task, no job ID + raw_records_count=0, + message=f"Aggregation triggered for work phase {body.work_phase}", + ), + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error( + f"Failed to retry work phase aggregation: {e}", exc_info=True + ) + return EndPomodoroResponse( + success=False, + message="Failed to retry work phase aggregation", + error=str(e), + timestamp=datetime.now().isoformat(), + ) + + +@api_handler( + body=GetSessionPhasesRequest, + method="POST", + path="/pomodoro/get-session-phases", + tags=["pomodoro"], +) +async def get_session_phases( + body: GetSessionPhasesRequest, +) -> GetSessionPhasesResponse: + """ + Get all work phase records for a session. + Used by frontend to display phase status and retry buttons. + """ + try: + db = get_db() + phases = await db.work_phases.get_by_session(body.session_id) + + phase_infos = [ + WorkPhaseInfo( + phase_id=p["id"], + phase_number=p["phase_number"], + status=p["status"], + processing_error=p.get("processing_error"), + retry_count=p.get("retry_count", 0), + phase_start_time=p["phase_start_time"], + phase_end_time=p.get("phase_end_time"), + activity_count=p.get("activity_count", 0), + ) + for p in phases + ] + + return GetSessionPhasesResponse( + success=True, data=phase_infos, timestamp=datetime.now().isoformat() + ) + + except Exception as e: + logger.error(f"Failed to get session phases: {e}", exc_info=True) + return GetSessionPhasesResponse( + success=False, error=str(e), timestamp=datetime.now().isoformat() + ) + + +@api_handler( + body=RetryLLMEvaluationRequest, + method="POST", + path="/pomodoro/retry-llm-evaluation", + tags=["pomodoro"], +) +async def retry_llm_evaluation( + body: RetryLLMEvaluationRequest, +) -> TimedOperationResponse: + """ + Manually retry LLM focus evaluation for a session. + Independent from phase aggregation retry. + """ + try: + coordinator = get_coordinator() + + if not coordinator.pomodoro_manager: + return TimedOperationResponse( + success=False, + error="Pomodoro manager not initialized", + timestamp=datetime.now().isoformat(), + ) + + # Trigger LLM evaluation (non-blocking) + asyncio.create_task( + coordinator.pomodoro_manager._compute_and_cache_llm_evaluation( + body.session_id + ) + ) + + return TimedOperationResponse( + success=True, + message="LLM evaluation retry triggered successfully", + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to retry LLM evaluation: {e}", exc_info=True) + return TimedOperationResponse( + success=False, error=str(e), timestamp=datetime.now().isoformat() + ) diff --git a/backend/handlers/pomodoro_goals.py b/backend/handlers/pomodoro_goals.py new file mode 100644 index 0000000..5d8b2ca --- /dev/null +++ b/backend/handlers/pomodoro_goals.py @@ -0,0 +1,137 @@ +""" +Pomodoro Goals Handler - API endpoints for managing focus time goals +""" + +from datetime import datetime +from typing import Optional + +from core.logger import get_logger +from core.settings import get_settings +from models.base import BaseModel +from models.responses import TimedOperationResponse + +# CRITICAL: Use relative import to avoid circular imports +from . import api_handler + +logger = get_logger(__name__) + + +# ============ Request Models ============ + + +class UpdatePomodoroGoalsRequest(BaseModel): + """Request to update Pomodoro goal settings""" + + daily_focus_goal_minutes: Optional[int] = None + weekly_focus_goal_minutes: Optional[int] = None + + +# ============ Response Models ============ + + +class PomodoroGoalsData(BaseModel): + """Pomodoro goal settings data""" + + daily_focus_goal_minutes: int + weekly_focus_goal_minutes: int + + +class GetPomodoroGoalsResponse(TimedOperationResponse): + """Response with Pomodoro goal settings""" + + data: Optional[PomodoroGoalsData] = None + + +class UpdatePomodoroGoalsResponse(TimedOperationResponse): + """Response with updated Pomodoro goal settings""" + + data: Optional[PomodoroGoalsData] = None + + +# ============ API Handlers ============ + + +@api_handler( + method="GET", + path="/pomodoro/goals", + tags=["pomodoro"], +) +async def get_pomodoro_goals() -> GetPomodoroGoalsResponse: + """ + Get Pomodoro focus time goal settings + + Returns: + - daily_focus_goal_minutes: Daily goal in minutes + - weekly_focus_goal_minutes: Weekly goal in minutes + """ + try: + settings = get_settings() + goals = settings.get_pomodoro_goal_settings() + + return GetPomodoroGoalsResponse( + success=True, + message="Retrieved Pomodoro goals", + data=PomodoroGoalsData( + daily_focus_goal_minutes=goals["daily_focus_goal_minutes"], + weekly_focus_goal_minutes=goals["weekly_focus_goal_minutes"], + ), + timestamp=datetime.now().isoformat(), + ) + except Exception as e: + logger.error(f"Failed to get Pomodoro goals: {e}", exc_info=True) + return GetPomodoroGoalsResponse( + success=False, + message=f"Failed to get goals: {str(e)}", + timestamp=datetime.now().isoformat(), + ) + + +@api_handler( + body=UpdatePomodoroGoalsRequest, + method="POST", + path="/pomodoro/goals", + tags=["pomodoro"], +) +async def update_pomodoro_goals( + body: UpdatePomodoroGoalsRequest, +) -> UpdatePomodoroGoalsResponse: + """ + Update Pomodoro focus time goal settings + + Args: + body: Contains daily and/or weekly goal values + + Returns: + Updated goal settings + """ + try: + settings = get_settings() + + # Build update dict with only provided fields + updates = {} + if body.daily_focus_goal_minutes is not None: + updates["daily_focus_goal_minutes"] = body.daily_focus_goal_minutes + if body.weekly_focus_goal_minutes is not None: + updates["weekly_focus_goal_minutes"] = body.weekly_focus_goal_minutes + + # Update settings + updated_goals = settings.update_pomodoro_goal_settings(updates) + + logger.info(f"Updated Pomodoro goals: {updated_goals}") + + return UpdatePomodoroGoalsResponse( + success=True, + message="Pomodoro goals updated successfully", + data=PomodoroGoalsData( + daily_focus_goal_minutes=updated_goals["daily_focus_goal_minutes"], + weekly_focus_goal_minutes=updated_goals["weekly_focus_goal_minutes"], + ), + timestamp=datetime.now().isoformat(), + ) + except Exception as e: + logger.error(f"Failed to update Pomodoro goals: {e}", exc_info=True) + return UpdatePomodoroGoalsResponse( + success=False, + message=f"Failed to update goals: {str(e)}", + timestamp=datetime.now().isoformat(), + ) diff --git a/backend/handlers/pomodoro_linking.py b/backend/handlers/pomodoro_linking.py new file mode 100644 index 0000000..849b0e6 --- /dev/null +++ b/backend/handlers/pomodoro_linking.py @@ -0,0 +1,296 @@ +""" +Pomodoro Activity Linking Handler - API endpoints for linking unlinked activities + +Provides endpoints to find and link unlinked activities to Pomodoro sessions +based on time overlap. +""" + +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from core.db import get_db +from core.logger import get_logger +from models.base import BaseModel +from models.responses import TimedOperationResponse + +# CRITICAL: Use relative import to avoid circular imports +from . import api_handler + +logger = get_logger(__name__) + + +# ============ Request Models ============ + + +class FindUnlinkedActivitiesRequest(BaseModel): + """Request to find activities that could be linked to a session""" + + session_id: str + + +class LinkActivitiesRequest(BaseModel): + """Request to link activities to a session""" + + session_id: str + activity_ids: List[str] + + +# ============ Response Models ============ + + +class UnlinkedActivityData(BaseModel): + """Activity that could be linked to session""" + + id: str + title: str + start_time: str + end_time: str + session_duration_minutes: int + + +class FindUnlinkedActivitiesResponse(TimedOperationResponse): + """Response with unlinked activities""" + + activities: List[UnlinkedActivityData] = [] + + +class LinkActivitiesResponse(TimedOperationResponse): + """Response after linking activities""" + + linked_count: int = 0 + + +# ============ API Handlers ============ + + +@api_handler( + body=FindUnlinkedActivitiesRequest, + method="POST", + path="/pomodoro/find-unlinked-activities", + tags=["pomodoro"], +) +async def find_unlinked_activities( + body: FindUnlinkedActivitiesRequest, +) -> FindUnlinkedActivitiesResponse: + """ + Find activities that overlap with session time but aren't linked + + Returns list of activities that could be retroactively linked + """ + try: + db = get_db() + + # Get session + session = await db.pomodoro_sessions.get_by_id(body.session_id) + if not session: + return FindUnlinkedActivitiesResponse( + success=False, + message=f"Session not found: {body.session_id}", + timestamp=datetime.now().isoformat(), + ) + + # Find overlapping activities + overlapping = await db.activities.find_unlinked_overlapping_activities( + session_start_time=session["start_time"], + session_end_time=session.get("end_time", datetime.now().isoformat()), + ) + + # Convert to response format + activity_data = [ + UnlinkedActivityData( + id=a["id"], + title=a["title"], + start_time=a["start_time"], + end_time=a["end_time"], + session_duration_minutes=a.get("session_duration_minutes", 0), + ) + for a in overlapping + ] + + logger.debug( + f"Found {len(activity_data)} unlinked activities for session {body.session_id}" + ) + + return FindUnlinkedActivitiesResponse( + success=True, + message=f"Found {len(activity_data)} unlinked activities", + activities=activity_data, + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to find unlinked activities: {e}", exc_info=True) + return FindUnlinkedActivitiesResponse( + success=False, + message=str(e), + timestamp=datetime.now().isoformat(), + ) + + +@api_handler( + body=LinkActivitiesRequest, + method="POST", + path="/pomodoro/link-activities", + tags=["pomodoro"], +) +async def link_activities_to_session( + body: LinkActivitiesRequest, +) -> LinkActivitiesResponse: + """ + Link selected activities to a Pomodoro session + + Updates activity records with pomodoro_session_id and auto-categorizes + work_phase based on the activity's time period + """ + try: + db = get_db() + + # Verify session exists + session = await db.pomodoro_sessions.get_by_id(body.session_id) + if not session: + return LinkActivitiesResponse( + success=False, + message=f"Session not found: {body.session_id}", + timestamp=datetime.now().isoformat(), + ) + + # Calculate phase timeline to determine work phases + phase_timeline = _calculate_phase_timeline_for_linking(session) + + # Link each activity with auto-categorized work phase + linked_count = 0 + for activity_id in body.activity_ids: + # Get activity to check its start time + activity = await db.activities.get_by_id(activity_id) + if not activity: + logger.warning(f"Activity {activity_id} not found, skipping") + continue + + # Determine work phase based on activity start time + work_phase = _determine_work_phase( + activity["start_time"], phase_timeline + ) + + # Link activity with auto-categorized work phase + count = await db.activities.link_activities_to_session( + activity_ids=[activity_id], + session_id=body.session_id, + work_phase=work_phase, + ) + linked_count += count + + logger.debug( + f"Linked activity {activity_id} to session {body.session_id}, " + f"phase: {work_phase or 'unassigned'}" + ) + + logger.info( + f"Linked {linked_count} activities to session {body.session_id} " + f"with auto-categorized phases" + ) + + return LinkActivitiesResponse( + success=True, + message=f"Successfully linked {linked_count} activities with auto-categorized phases", + linked_count=linked_count, + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to link activities: {e}", exc_info=True) + return LinkActivitiesResponse( + success=False, + message=str(e), + timestamp=datetime.now().isoformat(), + ) + + +# ============ Helper Functions ============ + + +def _calculate_phase_timeline_for_linking(session: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Calculate work phase timeline for a session + + Returns only work phases (not breaks) with their time ranges + """ + start_time = datetime.fromisoformat(session["start_time"]) + work_duration = session.get("work_duration_minutes", 25) + break_duration = session.get("break_duration_minutes", 5) + completed_rounds = session.get("completed_rounds", 0) + + timeline = [] + current_time = start_time + + for round_num in range(1, completed_rounds + 1): + # Work phase + work_end = current_time + timedelta(minutes=work_duration) + timeline.append({ + "phase_number": round_num, + "start_time": current_time.isoformat(), + "end_time": work_end.isoformat(), + }) + current_time = work_end + + # Add break duration to move to next work phase + current_time = current_time + timedelta(minutes=break_duration) + + return timeline + + +def _determine_work_phase( + activity_start_time: str, phase_timeline: List[Dict[str, Any]] +) -> Optional[int]: + """ + Determine which work phase an activity belongs to based on its start time + + Args: + activity_start_time: ISO format timestamp of activity start + phase_timeline: List of work phase dictionaries with start_time, end_time + + Returns: + Work phase number (1-based) or None if activity doesn't fall in any work phase + """ + try: + activity_time = datetime.fromisoformat(activity_start_time) + + for phase in phase_timeline: + phase_start = datetime.fromisoformat(phase["start_time"]) + phase_end = datetime.fromisoformat(phase["end_time"]) + + # Check if activity starts within this work phase + if phase_start <= activity_time <= phase_end: + return phase["phase_number"] + + # Activity doesn't fall within any work phase + # Assign to nearest work phase + nearest_phase = None + min_distance = None + + for phase in phase_timeline: + phase_start = datetime.fromisoformat(phase["start_time"]) + phase_end = datetime.fromisoformat(phase["end_time"]) + + # Calculate distance from activity to this phase + if activity_time < phase_start: + distance = (phase_start - activity_time).total_seconds() + elif activity_time > phase_end: + distance = (activity_time - phase_end).total_seconds() + else: + # This shouldn't happen as we already checked above + return phase["phase_number"] + + if min_distance is None or distance < min_distance: + min_distance = distance + nearest_phase = phase["phase_number"] + + logger.debug( + f"Activity at {activity_start_time} doesn't fall in any work phase, " + f"assigning to nearest phase: {nearest_phase}" + ) + + return nearest_phase + + except Exception as e: + logger.error(f"Error determining work phase: {e}", exc_info=True) + return None diff --git a/backend/handlers/pomodoro_phase_endpoints.py b/backend/handlers/pomodoro_phase_endpoints.py new file mode 100644 index 0000000..6ee0e88 --- /dev/null +++ b/backend/handlers/pomodoro_phase_endpoints.py @@ -0,0 +1,210 @@ +""" +New Pomodoro API endpoints for phase-level retry mechanisms. + +These endpoints should be added to backend/handlers/pomodoro.py +""" + +from datetime import datetime +from typing import List, Optional +import asyncio + +from models.base import BaseModel +from models.responses import ( + TimedOperationResponse, + WorkPhaseInfo, + GetSessionPhasesResponse, +) +from . import api_handler + +# ==================== Request Models ==================== + + +class RetryWorkPhaseRequest(BaseModel): + session_id: str + work_phase: int + + +class GetSessionPhasesRequest(BaseModel): + session_id: str + + +class RetryLLMEvaluationRequest(BaseModel): + session_id: str + + +# ==================== API Handlers ==================== + + +@api_handler( + body=RetryWorkPhaseRequest, + method="POST", + path="/pomodoro/retry-work-phase", + tags=["pomodoro"], +) +async def retry_work_phase_aggregation( + body: RetryWorkPhaseRequest, +) -> TimedOperationResponse: + """ + FIXED: Retry work phase aggregation using stored timing. + + Now uses phase_start_time/phase_end_time from phase record + instead of hardcoded calculations. + """ + from core.coordinator import get_coordinator + from core.db import get_db + from core.logger import get_logger + + logger = get_logger(__name__) + + try: + coordinator = get_coordinator() + db = get_db() + + if not coordinator.pomodoro_manager: + return TimedOperationResponse( + success=False, + error="Pomodoro manager not initialized", + timestamp=datetime.now().isoformat(), + ) + + # ★ Get phase record (contains accurate timing) ★ + phase_record = await db.work_phases.get_by_session_and_phase( + body.session_id, body.work_phase + ) + + if not phase_record: + return TimedOperationResponse( + success=False, + error=f"Phase record not found for session {body.session_id}, phase {body.work_phase}", + timestamp=datetime.now().isoformat(), + ) + + # ★ Extract timing from phase record (NOT calculated) ★ + phase_start_time = datetime.fromisoformat(phase_record["phase_start_time"]) + phase_end_time = datetime.fromisoformat(phase_record["phase_end_time"]) + + logger.info( + f"Manual retry: session={body.session_id}, phase={body.work_phase}, " + f"time_range={phase_start_time.isoformat()} to {phase_end_time.isoformat()}" + ) + + # Reset status for retry (clear error, reset retry count) + await db.work_phases.update_status(phase_record["id"], "pending", None, 0) + + # Trigger aggregation (non-blocking) + asyncio.create_task( + coordinator.pomodoro_manager._aggregate_work_phase_activities( + session_id=body.session_id, + work_phase=body.work_phase, + phase_start_time=phase_start_time, + phase_end_time=phase_end_time, + phase_id=phase_record["id"], + ) + ) + + return TimedOperationResponse( + success=True, + message=f"Work phase {body.work_phase} retry triggered successfully", + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to retry work phase: {e}", exc_info=True) + return TimedOperationResponse( + success=False, error=str(e), timestamp=datetime.now().isoformat() + ) + + +@api_handler( + body=GetSessionPhasesRequest, + method="POST", + path="/pomodoro/get-session-phases", + tags=["pomodoro"], +) +async def get_session_phases( + body: GetSessionPhasesRequest, +) -> GetSessionPhasesResponse: + """ + Get all work phase records for a session. + Used by frontend to display phase status and retry buttons. + """ + from core.db import get_db + from core.logger import get_logger + + logger = get_logger(__name__) + + try: + db = get_db() + phases = await db.work_phases.get_by_session(body.session_id) + + phase_infos = [ + WorkPhaseInfo( + phase_id=p["id"], + phase_number=p["phase_number"], + status=p["status"], + processing_error=p.get("processing_error"), + retry_count=p.get("retry_count", 0), + phase_start_time=p["phase_start_time"], + phase_end_time=p.get("phase_end_time"), + activity_count=p.get("activity_count", 0), + ) + for p in phases + ] + + return GetSessionPhasesResponse( + success=True, data=phase_infos, timestamp=datetime.now().isoformat() + ) + + except Exception as e: + logger.error(f"Failed to get session phases: {e}", exc_info=True) + return GetSessionPhasesResponse( + success=False, error=str(e), timestamp=datetime.now().isoformat() + ) + + +@api_handler( + body=RetryLLMEvaluationRequest, + method="POST", + path="/pomodoro/retry-llm-evaluation", + tags=["pomodoro"], +) +async def retry_llm_evaluation( + body: RetryLLMEvaluationRequest, +) -> TimedOperationResponse: + """ + Manually retry LLM focus evaluation for a session. + Independent from phase aggregation retry. + """ + from core.coordinator import get_coordinator + from core.logger import get_logger + + logger = get_logger(__name__) + + try: + coordinator = get_coordinator() + + if not coordinator.pomodoro_manager: + return TimedOperationResponse( + success=False, + error="Pomodoro manager not initialized", + timestamp=datetime.now().isoformat(), + ) + + # Trigger LLM evaluation (non-blocking, manual retry) + asyncio.create_task( + coordinator.pomodoro_manager._compute_and_cache_llm_evaluation( + body.session_id, is_first_attempt=False + ) + ) + + return TimedOperationResponse( + success=True, + message="LLM evaluation retry triggered successfully", + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to retry LLM evaluation: {e}", exc_info=True) + return TimedOperationResponse( + success=False, error=str(e), timestamp=datetime.now().isoformat() + ) diff --git a/backend/handlers/pomodoro_presets.py b/backend/handlers/pomodoro_presets.py new file mode 100644 index 0000000..c23ca13 --- /dev/null +++ b/backend/handlers/pomodoro_presets.py @@ -0,0 +1,123 @@ +""" +Pomodoro Configuration Presets - API endpoint for getting preset configurations + +Provides predefined Pomodoro configurations for common use cases. +""" + +from datetime import datetime +from typing import List + +from core.logger import get_logger +from models.base import BaseModel +from models.responses import TimedOperationResponse + +# CRITICAL: Use relative import to avoid circular imports +from . import api_handler + +logger = get_logger(__name__) + + +# ============ Response Models ============ + + +class PomodoroPreset(BaseModel): + """Pomodoro configuration preset""" + + id: str + name: str + description: str + work_duration_minutes: int + break_duration_minutes: int + total_rounds: int + icon: str = "⏱️" + + +class GetPomodoroPresetsResponse(TimedOperationResponse): + """Response with list of Pomodoro presets""" + + data: List[PomodoroPreset] = [] + + +# ============ Preset Definitions ============ + +POMODORO_PRESETS = [ + PomodoroPreset( + id="classic", + name="Classic Pomodoro", + description="Traditional 25/5 technique - 4 rounds", + work_duration_minutes=25, + break_duration_minutes=5, + total_rounds=4, + icon="🍅", + ), + PomodoroPreset( + id="deep-work", + name="Deep Work", + description="Extended focus sessions - 50/10 for intense work", + work_duration_minutes=50, + break_duration_minutes=10, + total_rounds=3, + icon="🎯", + ), + PomodoroPreset( + id="quick-sprint", + name="Quick Sprint", + description="Short bursts - 15/3 for quick tasks", + work_duration_minutes=15, + break_duration_minutes=3, + total_rounds=6, + icon="⚡", + ), + PomodoroPreset( + id="ultra-focus", + name="Ultra Focus", + description="Maximum concentration - 90/15 for deep thinking", + work_duration_minutes=90, + break_duration_minutes=15, + total_rounds=2, + icon="🧠", + ), + PomodoroPreset( + id="balanced", + name="Balanced Flow", + description="Moderate pace - 40/8 for sustained productivity", + work_duration_minutes=40, + break_duration_minutes=8, + total_rounds=4, + icon="⚖️", + ), +] + + +# ============ API Handler ============ + + +@api_handler(method="GET", path="/pomodoro/presets", tags=["pomodoro"]) +async def get_pomodoro_presets() -> GetPomodoroPresetsResponse: + """ + Get available Pomodoro configuration presets + + Returns a list of predefined configurations including: + - Classic Pomodoro (25/5) + - Deep Work (50/10) + - Quick Sprint (15/3) + - Ultra Focus (90/15) + - Balanced Flow (40/8) + """ + try: + logger.debug(f"Returning {len(POMODORO_PRESETS)} Pomodoro presets") + + return GetPomodoroPresetsResponse( + success=True, + message=f"Retrieved {len(POMODORO_PRESETS)} presets", + data=POMODORO_PRESETS, + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to get Pomodoro presets: {e}", exc_info=True) + return GetPomodoroPresetsResponse( + success=False, + message=f"Failed to get presets: {str(e)}", + timestamp=datetime.now().isoformat(), + ) diff --git a/backend/handlers/pomodoro_stats.py b/backend/handlers/pomodoro_stats.py new file mode 100644 index 0000000..81ed21d --- /dev/null +++ b/backend/handlers/pomodoro_stats.py @@ -0,0 +1,760 @@ +""" +Pomodoro Statistics Handler - API endpoints for Pomodoro session statistics + +Endpoints: +- POST /pomodoro/stats - Get Pomodoro statistics for a specific date +- POST /pomodoro/session-detail - Get detailed session data with activities +- DELETE /pomodoro/sessions/delete - Delete a session and cascade delete activities +""" + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from core.db import get_db +from core.events import emit_pomodoro_session_deleted +from core.logger import get_logger +from llm.focus_evaluator import get_focus_evaluator +from models.base import BaseModel +from models.responses import ( + DeletePomodoroSessionData, + DeletePomodoroSessionRequest, + DeletePomodoroSessionResponse, + FocusMetrics, + GetPomodoroSessionDetailRequest, + GetPomodoroSessionDetailResponse, + LLMFocusAnalysis, + LLMFocusDimensionScores, + LLMFocusEvaluation, + PhaseTimelineItem, + PomodoroActivityData, + PomodoroSessionData, + PomodoroSessionDetailData, + TimedOperationResponse, +) + +# CRITICAL: Use relative import to avoid circular imports +from . import api_handler + +logger = get_logger(__name__) + + +# ============ Request Models ============ + + +class GetPomodoroStatsRequest(BaseModel): + """Request to get Pomodoro statistics for a specific date""" + + date: str # YYYY-MM-DD format + + +class GetPomodoroPeriodStatsRequest(BaseModel): + """Request to get Pomodoro statistics for a time period""" + + period: str # "week", "month", or "year" + reference_date: Optional[str] = None # YYYY-MM-DD format (defaults to today) + + +# ============ Response Models ============ + + +class PomodoroStatsData(BaseModel): + """Pomodoro statistics for a specific date""" + + date: str + completed_count: int + total_focus_minutes: int + average_duration_minutes: int + sessions: List[Dict[str, Any]] # Recent sessions for the day + + +class GetPomodoroStatsResponse(TimedOperationResponse): + """Response with Pomodoro statistics""" + + data: Optional[PomodoroStatsData] = None + + +class DailyFocusData(BaseModel): + """Daily focus data for a specific day""" + + day: str # Day label (e.g., "Mon", "周一") + date: str # YYYY-MM-DD format + sessions: int + minutes: int + + +class PomodoroPeriodStatsData(BaseModel): + """Pomodoro statistics for a time period""" + + period: str # "week", "month", or "year" + start_date: str # YYYY-MM-DD + end_date: str # YYYY-MM-DD + weekly_total: int # Total sessions in period + focus_hours: float # Total focus hours + daily_average: float # Average sessions per day + completion_rate: int # Percentage of goal completion + daily_data: List[DailyFocusData] # Daily breakdown + + +class GetPomodoroPeriodStatsResponse(TimedOperationResponse): + """Response with Pomodoro period statistics""" + + data: Optional[PomodoroPeriodStatsData] = None + + +# ============ API Handlers ============ + + +@api_handler( + body=GetPomodoroStatsRequest, + method="POST", + path="/pomodoro/stats", + tags=["pomodoro"], +) +async def get_pomodoro_stats( + body: GetPomodoroStatsRequest, +) -> GetPomodoroStatsResponse: + """ + Get Pomodoro statistics for a specific date + + Returns: + - Number of completed sessions + - Total focus time (minutes) + - Average session duration (minutes) + - List of all sessions for that day + """ + try: + db = get_db() + + # Validate date format + try: + datetime.fromisoformat(body.date) + except ValueError: + return GetPomodoroStatsResponse( + success=False, + message="Invalid date format. Expected YYYY-MM-DD", + timestamp=datetime.now().isoformat(), + ) + + # Get daily stats from repository + stats = await db.pomodoro_sessions.get_daily_stats(body.date) + + # Optionally fetch associated TODO titles and activity counts for sessions + sessions_with_todos = [] + for session in stats.get("sessions", []): + session_data = dict(session) + + # If session has associated_todo_id, fetch TODO title + if session_data.get("associated_todo_id"): + try: + todo = await db.todos.get_by_id(session_data["associated_todo_id"]) + if todo and not todo.get("deleted"): + session_data["associated_todo_title"] = todo.get("title") + else: + session_data["associated_todo_title"] = None + except Exception as e: + logger.warning( + f"Failed to fetch TODO for session {session_data.get('id')}: {e}" + ) + session_data["associated_todo_title"] = None + + # Use actual_duration_minutes as pure work duration + # This reflects actual work time (completed rounds + partial current round if stopped early) + session_data["pure_work_duration_minutes"] = session_data.get("actual_duration_minutes", 0) + + # Get activity count for this session + session_id = session_data.get("id") + if session_id: + try: + activities = await db.activities.get_by_pomodoro_session(session_id) + session_data["activity_count"] = len(activities) + except Exception as e: + logger.warning( + f"Failed to fetch activities for session {session_id}: {e}" + ) + session_data["activity_count"] = 0 + else: + session_data["activity_count"] = 0 + + sessions_with_todos.append(session_data) + + logger.debug( + f"Retrieved Pomodoro stats for {body.date}: " + f"{stats['completed_count']} completed, " + f"{stats['total_focus_minutes']} minutes" + ) + + return GetPomodoroStatsResponse( + success=True, + message=f"Retrieved statistics for {body.date}", + data=PomodoroStatsData( + date=body.date, + completed_count=stats["completed_count"], + total_focus_minutes=stats["total_focus_minutes"], + average_duration_minutes=stats["average_duration_minutes"], + sessions=sessions_with_todos, + ), + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to get Pomodoro stats: {e}", exc_info=True) + return GetPomodoroStatsResponse( + success=False, + message=f"Failed to get statistics: {str(e)}", + timestamp=datetime.now().isoformat(), + ) + + +@api_handler( + body=GetPomodoroSessionDetailRequest, + method="POST", + path="/pomodoro/session-detail", + tags=["pomodoro"], +) +async def get_pomodoro_session_detail( + body: GetPomodoroSessionDetailRequest, +) -> GetPomodoroSessionDetailResponse: + """ + Get detailed Pomodoro session with activities and focus metrics + + Returns: + - Full session data + - All activities generated during this session (ordered by work phase) + - Calculated focus metrics (overall_focus_score, activity_count, topic_diversity, etc.) + """ + try: + db = get_db() + + # Get session + session = await db.pomodoro_sessions.get_by_id(body.session_id) + if not session: + return GetPomodoroSessionDetailResponse( + success=False, + message=f"Session not found: {body.session_id}", + timestamp=datetime.now().isoformat(), + ) + + # Get activities for this session + activities = await db.activities.get_by_pomodoro_session(body.session_id) + + # Convert activities to Pydantic models + activity_data_list = [ + PomodoroActivityData( + id=activity["id"], + title=activity["title"], + description=activity["description"], + start_time=activity["start_time"], + end_time=activity["end_time"], + session_duration_minutes=activity.get("session_duration_minutes") or 0, + work_phase=activity.get("pomodoro_work_phase"), + focus_score=activity.get("focus_score"), + topic_tags=activity.get("topic_tags") or [], + source_event_ids=activity.get("source_event_ids") or [], + source_action_ids=activity.get("source_action_ids") or [], + aggregation_mode=activity.get("aggregation_mode", "action_based"), + ) + for activity in activities + ] + + # Calculate focus metrics + focus_metrics_dict = _calculate_session_focus_metrics(session, activities) + focus_metrics = FocusMetrics( + overall_focus_score=focus_metrics_dict["overall_focus_score"], + activity_count=focus_metrics_dict["activity_count"], + topic_diversity=focus_metrics_dict["topic_diversity"], + average_activity_duration=focus_metrics_dict["average_activity_duration"], + focus_level=focus_metrics_dict["focus_level"], + ) + + # Use actual_duration_minutes as pure work duration + # This reflects actual work time (completed rounds + partial current round if stopped early) + session_with_pure_duration = dict(session) + session_with_pure_duration["pure_work_duration_minutes"] = session_with_pure_duration.get("actual_duration_minutes", 0) + + # Calculate phase timeline + phase_timeline_raw = await _calculate_phase_timeline(session) + phase_timeline = [ + PhaseTimelineItem( + phase_type=phase["phase_type"], + phase_number=phase["phase_number"], + start_time=phase["start_time"], + end_time=phase["end_time"], + duration_minutes=phase["duration_minutes"], + ) + for phase in phase_timeline_raw + ] + + logger.debug( + f"Retrieved session detail for {body.session_id}: " + f"{len(activities)} activities, " + f"focus score: {focus_metrics.overall_focus_score:.2f}" + ) + + # LLM-based focus evaluation (cache-first with on-demand fallback) + llm_evaluation = None + try: + # Step 1: Try to load from cache first + cached_result = await db.pomodoro_sessions.get_llm_evaluation(body.session_id) + + if cached_result: + # Cache hit - use cached result + logger.debug(f"Using cached LLM evaluation for {body.session_id}") + llm_evaluation = LLMFocusEvaluation( + focus_score=cached_result["focus_score"], + focus_level=cached_result["focus_level"], + dimension_scores=LLMFocusDimensionScores(**cached_result["dimension_scores"]), + analysis=LLMFocusAnalysis(**cached_result["analysis"]), + work_type=cached_result["work_type"], + is_focused_work=cached_result["is_focused_work"], + distraction_percentage=cached_result["distraction_percentage"], + deep_work_minutes=cached_result["deep_work_minutes"], + context_summary=cached_result["context_summary"], + ) + else: + # Step 2: Cache miss - compute on-demand (backward compatibility) + logger.info( + f"Cache miss, computing on-demand LLM evaluation for {body.session_id}" + ) + + focus_evaluator = get_focus_evaluator() + llm_result = await focus_evaluator.evaluate_focus( + activities=activities, + session_info=session, + ) + + # Convert to Pydantic model + llm_evaluation = LLMFocusEvaluation( + focus_score=llm_result["focus_score"], + focus_level=llm_result["focus_level"], + dimension_scores=LLMFocusDimensionScores(**llm_result["dimension_scores"]), + analysis=LLMFocusAnalysis(**llm_result["analysis"]), + work_type=llm_result["work_type"], + is_focused_work=llm_result["is_focused_work"], + distraction_percentage=llm_result["distraction_percentage"], + deep_work_minutes=llm_result["deep_work_minutes"], + context_summary=llm_result["context_summary"], + ) + + # Step 3: Cache the result for future requests + try: + await db.pomodoro_sessions.update_llm_evaluation( + body.session_id, llm_result + ) + logger.info(f"Cached on-demand evaluation for {body.session_id}") + except Exception as cache_error: + logger.warning( + f"Failed to cache on-demand evaluation: {cache_error}" + ) + # Continue - caching failure doesn't affect response + + logger.info( + f"LLM focus evaluation completed for {body.session_id}: " + f"score={llm_evaluation.focus_score}, level={llm_evaluation.focus_level}" + ) + + except Exception as e: + logger.warning( + f"LLM focus evaluation failed for {body.session_id}: {e}. " + f"Continuing with basic metrics only." + ) + # Continue without LLM evaluation - it's optional + + return GetPomodoroSessionDetailResponse( + success=True, + message="Session details retrieved", + data=PomodoroSessionDetailData( + session=session_with_pure_duration, + activities=activity_data_list, + focus_metrics=focus_metrics, + llm_focus_evaluation=llm_evaluation, + phase_timeline=phase_timeline, + ), + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error( + f"Failed to get session detail for {body.session_id}: {e}", + exc_info=True, + ) + return GetPomodoroSessionDetailResponse( + success=False, + message=f"Failed to get session details: {str(e)}", + timestamp=datetime.now().isoformat(), + ) + + +# ============ Helper Functions ============ + + +def _calculate_session_focus_metrics( + session: Dict[str, Any], activities: List[Dict[str, Any]] +) -> Dict[str, Any]: + """ + Calculate session-level focus metrics + + Metrics: + - overall_focus_score: Weighted average of activity focus scores (by duration) + - activity_count: Number of activities in session + - topic_diversity: Number of unique topics across all activities + - average_activity_duration: Average duration per activity (minutes) + - focus_level: Human-readable level (excellent/good/moderate/low) + + Args: + session: Session dictionary + activities: List of activity dictionaries + + Returns: + Dictionary with calculated metrics + """ + if not activities: + return { + "overall_focus_score": 0.0, + "activity_count": 0, + "topic_diversity": 0, + "average_activity_duration": 0, + "focus_level": "low", + } + + # Calculate weighted average focus score (weighted by activity duration) + total_duration = sum( + activity.get("session_duration_minutes") or 0 for activity in activities + ) + + if total_duration > 0: + weighted_score = sum( + (activity.get("focus_score") or 0.5) + * (activity.get("session_duration_minutes") or 0) + for activity in activities + ) / total_duration + else: + # If no duration info, use simple average + weighted_score = sum( + activity.get("focus_score") or 0.5 for activity in activities + ) / len(activities) + + # Calculate topic diversity + all_topics = set() + for activity in activities: + all_topics.update(activity.get("topic_tags") or []) + + # Calculate average activity duration + average_duration = ( + total_duration / len(activities) if len(activities) > 0 else 0 + ) + + # Map score to focus level + focus_level = _get_focus_level(weighted_score) + + return { + "overall_focus_score": round(weighted_score, 2), + "activity_count": len(activities), + "topic_diversity": len(all_topics), + "average_activity_duration": round(average_duration, 1), + "focus_level": focus_level, + } + + +def _get_focus_level(score: float) -> str: + """ + Map focus score to human-readable level + + Args: + score: Focus score (0.0-1.0) + + Returns: + Focus level: "excellent", "good", "moderate", or "low" + """ + if score >= 0.8: + return "excellent" + elif score >= 0.6: + return "good" + elif score >= 0.4: + return "moderate" + else: + return "low" + + +async def _calculate_phase_timeline(session: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Reconstruct work/break phase timeline from session data + + Calculates the timeline based on completed_rounds and actual session duration. + For the last round, uses actual end_time instead of configured duration. + + Args: + session: Session dictionary with metadata + + Returns: + List of phase dictionaries with start_time, end_time, phase_type, phase_number + """ + from datetime import timedelta + + start_time = datetime.fromisoformat(session["start_time"]) + end_time_str = session.get("end_time") + work_duration = session.get("work_duration_minutes", 25) + break_duration = session.get("break_duration_minutes", 5) + completed_rounds = session.get("completed_rounds", 0) + total_rounds = session.get("total_rounds", 4) + status = session.get("status", "active") + + if completed_rounds == 0: + return [] + + timeline = [] + current_time = start_time + + # Calculate timeline for all completed rounds + for round_num in range(1, completed_rounds + 1): + is_last_round = (round_num == completed_rounds) + + # Work phase + if is_last_round and end_time_str: + # Last round - use actual end_time + work_end = datetime.fromisoformat(end_time_str) + duration = int((work_end - current_time).total_seconds() / 60) + else: + # Complete round - use configured duration + work_end = current_time + timedelta(minutes=work_duration) + duration = work_duration + + timeline.append({ + "phase_type": "work", + "phase_number": round_num, + "start_time": current_time.isoformat(), + "end_time": work_end.isoformat(), + "duration_minutes": duration, + }) + current_time = work_end + + # Break phase - only add if NOT the last completed round + # (i.e., there's another work round after this one) + should_add_break = not is_last_round + + if should_add_break: + break_end = current_time + timedelta(minutes=break_duration) + timeline.append({ + "phase_type": "break", + "phase_number": round_num, + "start_time": current_time.isoformat(), + "end_time": break_end.isoformat(), + "duration_minutes": break_duration, + }) + current_time = break_end + + return timeline + + +@api_handler( + body=GetPomodoroPeriodStatsRequest, + method="POST", + path="/pomodoro/period-stats", + tags=["pomodoro"], +) +async def get_pomodoro_period_stats( + body: GetPomodoroPeriodStatsRequest, +) -> GetPomodoroPeriodStatsResponse: + """ + Get Pomodoro statistics for a time period (week/month/year) + + Returns: + - Period summary statistics (total sessions, focus hours, daily average, completion rate) + - Daily breakdown data for visualization + """ + try: + from datetime import timedelta + + db = get_db() + + # Get reference date (default to today) + if body.reference_date: + try: + reference_date = datetime.fromisoformat(body.reference_date).date() + except ValueError: + return GetPomodoroPeriodStatsResponse( + success=False, + message="Invalid reference_date format. Expected YYYY-MM-DD", + timestamp=datetime.now().isoformat(), + ) + else: + reference_date = datetime.now().date() + + # Calculate period range + if body.period == "week": + # Last 7 days including today + start_date = reference_date - timedelta(days=6) + end_date = reference_date + daily_count = 7 + elif body.period == "month": + # Last 30 days + start_date = reference_date - timedelta(days=29) + end_date = reference_date + daily_count = 30 + elif body.period == "year": + # Last 365 days + start_date = reference_date - timedelta(days=364) + end_date = reference_date + daily_count = 365 + else: + return GetPomodoroPeriodStatsResponse( + success=False, + message=f"Invalid period: {body.period}. Must be 'week', 'month', or 'year'", + timestamp=datetime.now().isoformat(), + ) + + # Fetch daily stats for the entire period + daily_data = [] + total_sessions = 0 + total_minutes = 0 + + current_date = start_date + while current_date <= end_date: + date_str = current_date.isoformat() + day_stats = await db.pomodoro_sessions.get_daily_stats(date_str) + + # Get day label (weekday name) + day_label = current_date.strftime("%a") # Mon, Tue, etc. + + daily_data.append( + DailyFocusData( + day=day_label, + date=date_str, + sessions=day_stats["completed_count"], + minutes=day_stats["total_focus_minutes"], + ) + ) + + total_sessions += day_stats["completed_count"] + total_minutes += day_stats["total_focus_minutes"] + + current_date += timedelta(days=1) + + # Calculate summary statistics + focus_hours = round(total_minutes / 60, 1) + daily_average = round(total_sessions / daily_count, 1) + + # Calculate completion rate based on user's focus time goal + from core.settings import get_settings + + settings = get_settings() + goals = settings.get_pomodoro_goal_settings() + + # Determine goal based on period + if body.period == "week": + goal_minutes = goals["weekly_focus_goal_minutes"] + elif body.period == "month": + # Pro-rate weekly goal to monthly (assume 4.3 weeks/month) + goal_minutes = int(goals["weekly_focus_goal_minutes"] * 4.3) + elif body.period == "year": + # Pro-rate weekly goal to yearly (52 weeks/year) + goal_minutes = goals["weekly_focus_goal_minutes"] * 52 + else: + goal_minutes = goals["daily_focus_goal_minutes"] + + # Calculate completion rate based on total focus time (not session count) + completion_rate = min(100, int((total_minutes / goal_minutes) * 100)) if goal_minutes > 0 else 0 + + logger.debug( + f"Retrieved Pomodoro period stats for {body.period}: " + f"{total_sessions} sessions, {focus_hours} hours" + ) + + return GetPomodoroPeriodStatsResponse( + success=True, + message=f"Retrieved statistics for {body.period}", + data=PomodoroPeriodStatsData( + period=body.period, + start_date=start_date.isoformat(), + end_date=end_date.isoformat(), + weekly_total=total_sessions, + focus_hours=focus_hours, + daily_average=daily_average, + completion_rate=completion_rate, + daily_data=daily_data, + ), + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to get Pomodoro period stats: {e}", exc_info=True) + return GetPomodoroPeriodStatsResponse( + success=False, + message=f"Failed to get period statistics: {str(e)}", + timestamp=datetime.now().isoformat(), + ) + + +@api_handler( + body=DeletePomodoroSessionRequest, + method="DELETE", + path="/pomodoro/sessions/delete", + tags=["pomodoro"], +) +async def delete_pomodoro_session( + body: DeletePomodoroSessionRequest, +) -> DeletePomodoroSessionResponse: + """ + Delete a Pomodoro session and cascade delete all linked activities + + This operation: + 1. Validates session exists and is not already deleted + 2. Soft deletes all activities linked to this session (cascade) + 3. Soft deletes the session itself + 4. Emits deletion event to notify frontend + + Args: + body: Request containing session_id + + Returns: + Response with deletion result and count of cascade-deleted activities + """ + try: + db = get_db() + + # Validate session exists and is not deleted + session = await db.pomodoro_sessions.get_by_id(body.session_id) + if not session: + return DeletePomodoroSessionResponse( + success=False, + error="Session not found or already deleted", + timestamp=datetime.now().isoformat(), + ) + + # CASCADE: Soft delete all activities linked to this session + deleted_activities_count = await db.activities.delete_by_session_id( + body.session_id + ) + + # Soft delete the session + await db.pomodoro_sessions.soft_delete(body.session_id) + + # Emit deletion event to frontend + emit_pomodoro_session_deleted( + body.session_id, datetime.now().isoformat() + ) + + logger.info( + f"Deleted Pomodoro session {body.session_id} " + f"and cascade deleted {deleted_activities_count} activities" + ) + + return DeletePomodoroSessionResponse( + success=True, + message=f"Session deleted successfully. {deleted_activities_count} linked activities also removed.", + data=DeletePomodoroSessionData( + session_id=body.session_id, + deleted_activities_count=deleted_activities_count, + ), + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error( + f"Failed to delete Pomodoro session {body.session_id}: {e}", + exc_info=True, + ) + return DeletePomodoroSessionResponse( + success=False, + error=f"Failed to delete session: {str(e)}", + timestamp=datetime.now().isoformat(), + ) diff --git a/backend/handlers/resources.py b/backend/handlers/resources.py index 746dcf1..46b4409 100644 --- a/backend/handlers/resources.py +++ b/backend/handlers/resources.py @@ -21,6 +21,7 @@ from core.settings import get_settings from models.base import OperationResponse, TimedOperationResponse from models.requests import ( + CleanupBrokenActionsRequest, CleanupImagesRequest, CreateModelRequest, DeleteModelRequest, @@ -36,10 +37,14 @@ ) from models.responses import ( CachedImagesResponse, + CleanupBrokenActionsResponse, CleanupImagesResponse, + CleanupSoftDeletedResponse, ClearMemoryCacheResponse, ImageOptimizationConfigResponse, ImageOptimizationStatsResponse, + ImagePersistenceHealthData, + ImagePersistenceHealthResponse, ImageStatsResponse, ReadImageFileResponse, UpdateImageOptimizationConfigResponse, @@ -373,6 +378,231 @@ async def read_image_file(body: ReadImageFileRequest) -> ReadImageFileResponse: return ReadImageFileResponse(success=False, error=str(e)) +@api_handler( + body=None, method="GET", path="/image/persistence-health", tags=["image"] +) +async def check_image_persistence_health() -> ImagePersistenceHealthResponse: + """ + Check health of image persistence system + + Analyzes all actions with screenshots to determine how many have missing + image files on disk. Provides statistics for diagnostics. + + Returns: + Health check results with statistics + """ + try: + db = get_db() + image_manager = get_image_manager() + + # Get all actions with screenshots (limit to 1000 for performance) + actions = await db.actions.get_all_actions_with_screenshots(limit=1000) + + total_actions = len(actions) + actions_all_ok = 0 + actions_partial_missing = 0 + actions_all_missing = 0 + total_references = 0 + images_found = 0 + images_missing = 0 + actions_with_issues = [] + + for action in actions: + screenshots = action.get("screenshots", []) + if not screenshots: + continue + + total_references += len(screenshots) + missing_hashes = [] + + # Check each screenshot + for img_hash in screenshots: + thumbnail_path = image_manager.thumbnails_dir / f"{img_hash}.jpg" + if thumbnail_path.exists(): + images_found += 1 + else: + images_missing += 1 + missing_hashes.append(img_hash) + + # Classify action based on missing images + if not missing_hashes: + actions_all_ok += 1 + elif len(missing_hashes) == len(screenshots): + actions_all_missing += 1 + # Sample first 10 actions with all images missing + if len(actions_with_issues) < 10: + actions_with_issues.append({ + "id": action["id"], + "created_at": action["created_at"], + "total_screenshots": len(screenshots), + "missing_screenshots": len(missing_hashes), + "status": "all_missing", + }) + else: + actions_partial_missing += 1 + # Sample first 10 actions with partial missing + if len(actions_with_issues) < 10: + actions_with_issues.append({ + "id": action["id"], + "created_at": action["created_at"], + "total_screenshots": len(screenshots), + "missing_screenshots": len(missing_hashes), + "status": "partial_missing", + }) + + # Calculate missing rate + missing_rate = ( + (images_missing / total_references * 100) if total_references > 0 else 0.0 + ) + + # Get cache stats + cache_stats = image_manager.get_stats() + + data = ImagePersistenceHealthData( + total_actions=total_actions, + actions_with_screenshots=total_actions, + actions_all_images_ok=actions_all_ok, + actions_partial_missing=actions_partial_missing, + actions_all_missing=actions_all_missing, + total_image_references=total_references, + images_found=images_found, + images_missing=images_missing, + missing_rate_percent=round(missing_rate, 2), + memory_cache_current_size=cache_stats.get("cache_count", 0), + memory_cache_max_size=cache_stats.get("cache_limit", 0), + memory_ttl_seconds=cache_stats.get("memory_ttl", 0), + actions_with_issues=actions_with_issues, + ) + + logger.info( + f"Image persistence health check: {images_missing}/{total_references} images missing " + f"({missing_rate:.2f}%), {actions_all_missing} actions with all images missing" + ) + + return ImagePersistenceHealthResponse( + success=True, + message=f"Health check completed: {missing_rate:.2f}% images missing", + data=data, + ) + + except Exception as e: + logger.error(f"Failed to check image persistence health: {e}", exc_info=True) + return ImagePersistenceHealthResponse(success=False, error=str(e)) + + +@api_handler( + body=CleanupBrokenActionsRequest, + method="POST", + path="/image/cleanup-broken-actions", + tags=["image"], +) +async def cleanup_broken_action_images( + body: CleanupBrokenActionsRequest, +) -> CleanupBrokenActionsResponse: + """ + Clean up actions with missing image references + + Supports three strategies: + - delete_actions: Soft-delete actions with all images missing + - remove_references: Clear image references, keep action metadata + - dry_run: Report what would be cleaned without making changes + + Args: + body: Cleanup request with strategy and optional action IDs + + Returns: + Cleanup results with statistics + """ + try: + db = get_db() + image_manager = get_image_manager() + + # Get actions to process + if body.action_ids: + # Process specific actions + actions = [] + for action_id in body.action_ids: + action = await db.actions.get_by_id(action_id) + if action: + actions.append(action) + else: + # Process all actions with screenshots + actions = await db.actions.get_all_actions_with_screenshots(limit=10000) + + actions_processed = 0 + actions_deleted = 0 + references_removed = 0 + + for action in actions: + screenshots = action.get("screenshots", []) + if not screenshots: + continue + + # Check which images are missing + missing_hashes = [] + for img_hash in screenshots: + thumbnail_path = image_manager.thumbnails_dir / f"{img_hash}.jpg" + if not thumbnail_path.exists(): + missing_hashes.append(img_hash) + + if not missing_hashes: + continue # All images present + + actions_processed += 1 + all_missing = len(missing_hashes) == len(screenshots) + + if body.strategy == "delete_actions" and all_missing: + # Only delete if all images are missing + logger.info( + f"Deleted action {action['id']} with {len(screenshots)} missing images" + ) + await db.actions.delete(action["id"]) + actions_deleted += 1 + + elif body.strategy == "remove_references": + # Remove screenshot references + logger.info( + f"Removed screenshot references from action {action['id']}" + ) + removed = await db.actions.remove_screenshots(action["id"]) + references_removed += removed + + elif body.strategy == "dry_run": + # Dry run - just log what would be done + if all_missing: + logger.info( + f"[DRY RUN] Would delete action {action['id']} " + f"with {len(screenshots)} missing images" + ) + else: + logger.info( + f"[DRY RUN] Would remove {len(missing_hashes)} " + f"screenshot references from action {action['id']}" + ) + + message = f"Cleanup completed ({body.strategy}): " + if body.strategy == "delete_actions": + message += f"deleted {actions_deleted} actions" + elif body.strategy == "remove_references": + message += f"removed {references_removed} references" + else: # dry_run + message += f"would process {actions_processed} actions" + + logger.info(message) + + return CleanupBrokenActionsResponse( + success=True, + message=message, + actions_processed=actions_processed, + actions_deleted=actions_deleted, + references_removed=references_removed, + ) + + except Exception as e: + logger.error(f"Failed to cleanup broken actions: {e}", exc_info=True) + return CleanupBrokenActionsResponse(success=False, error=str(e)) + + # ============================================================================ # Model Management # ============================================================================ @@ -1127,3 +1357,51 @@ async def get_llm_usage_trend( message=f"Failed to get LLM usage trend: {str(e)}", timestamp=datetime.now().isoformat(), ) + + +# ============ Soft-Deleted Items Cleanup ============ + + +@api_handler( + method="POST", + path="/resources/cleanup-soft-deleted", + tags=["resources"], + summary="Permanently delete soft-deleted items", + description="Permanently delete all soft-deleted items (todos, knowledge, etc.) from the database", +) +async def cleanup_soft_deleted_items() -> CleanupSoftDeletedResponse: + """Permanently delete all soft-deleted items + + This cleanup operation permanently removes items that have been + soft-deleted (deleted = 1) from the database. + + Currently supports: + - Todos + + @returns Cleanup result with counts for each item type + """ + try: + db = get_db() + + results: Dict[str, int] = {} + + # Clean up soft-deleted todos + todos_deleted = await db.todos.delete_soft_deleted_permanent() + results["todos"] = todos_deleted + + total_deleted = sum(results.values()) + + return CleanupSoftDeletedResponse( + success=True, + message=f"Permanently deleted {total_deleted} soft-deleted items", + data=results, + timestamp=datetime.now().isoformat(), + ) + + except Exception as e: + logger.error(f"Failed to cleanup soft-deleted items: {e}", exc_info=True) + return CleanupSoftDeletedResponse( + success=False, + message=f"Failed to cleanup soft-deleted items: {str(e)}", + timestamp=datetime.now().isoformat(), + ) diff --git a/backend/handlers/system.py b/backend/handlers/system.py index 56a46ba..f80803f 100644 --- a/backend/handlers/system.py +++ b/backend/handlers/system.py @@ -176,6 +176,10 @@ async def get_settings_info() -> GetSettingsInfoResponse: settings = get_settings() all_settings = settings.get_all() + # Get voice and clock settings + voice_settings = settings.get_voice_settings() + clock_settings = settings.get_clock_settings() + return GetSettingsInfoResponse( success=True, data=SettingsInfoData( @@ -183,6 +187,23 @@ async def get_settings_info() -> GetSettingsInfoResponse: database={"path": settings.get_database_path()}, screenshot={"savePath": settings.get_screenshot_path()}, language=settings.get_language(), + font_size=settings.get_font_size(), + voice={ + "enabled": voice_settings["enabled"], + "volume": voice_settings["volume"], + "soundTheme": voice_settings.get("sound_theme", "8bit"), + "customSounds": voice_settings.get("custom_sounds") + }, + clock={ + "enabled": clock_settings["enabled"], + "position": clock_settings["position"], + "size": clock_settings["size"], + "customX": clock_settings.get("custom_x"), + "customY": clock_settings.get("custom_y"), + "customWidth": clock_settings.get("custom_width"), + "customHeight": clock_settings.get("custom_height"), + "useCustomPosition": clock_settings.get("use_custom_position", False) + }, image={ "memoryCacheSize": int(settings.get("image.memory_cache_size", 500)) }, @@ -231,6 +252,81 @@ async def update_settings(body: UpdateSettingsRequest) -> UpdateSettingsResponse timestamp=timestamp, ) + # Update font size + if body.font_size: + if not settings.set_font_size(body.font_size): + return UpdateSettingsResponse( + success=False, + message="Failed to update font size. Must be 'small', 'default', 'large', or 'extra-large'", + timestamp=timestamp, + ) + + # Update notification sound settings (kept as voice for backward compatibility) + if ( + body.voice_enabled is not None + or body.voice_volume is not None + or body.voice_sound_theme is not None + or body.voice_custom_sounds is not None + ): + voice_updates = {} + if body.voice_enabled is not None: + voice_updates["enabled"] = body.voice_enabled + if body.voice_volume is not None: + voice_updates["volume"] = body.voice_volume + if body.voice_sound_theme is not None: + voice_updates["sound_theme"] = body.voice_sound_theme + if body.voice_custom_sounds is not None: + voice_updates["custom_sounds"] = body.voice_custom_sounds + + try: + settings.update_voice_settings(voice_updates) + except Exception as e: + logger.error(f"Failed to update notification sound settings: {e}") + return UpdateSettingsResponse( + success=False, + message=f"Failed to update voice settings: {str(e)}", + timestamp=timestamp, + ) + + # Update clock settings + if ( + body.clock_enabled is not None + or body.clock_position is not None + or body.clock_size is not None + or body.clock_custom_x is not None + or body.clock_custom_y is not None + or body.clock_custom_width is not None + or body.clock_custom_height is not None + or body.clock_use_custom_position is not None + ): + clock_updates = {} + if body.clock_enabled is not None: + clock_updates["enabled"] = body.clock_enabled + if body.clock_position is not None: + clock_updates["position"] = body.clock_position + if body.clock_size is not None: + clock_updates["size"] = body.clock_size + if body.clock_custom_x is not None: + clock_updates["custom_x"] = body.clock_custom_x + if body.clock_custom_y is not None: + clock_updates["custom_y"] = body.clock_custom_y + if body.clock_custom_width is not None: + clock_updates["custom_width"] = body.clock_custom_width + if body.clock_custom_height is not None: + clock_updates["custom_height"] = body.clock_custom_height + if body.clock_use_custom_position is not None: + clock_updates["use_custom_position"] = body.clock_use_custom_position + + try: + settings.update_clock_settings(clock_updates) + except Exception as e: + logger.error(f"Failed to update clock settings: {e}") + return UpdateSettingsResponse( + success=False, + message=f"Failed to update clock settings: {str(e)}", + timestamp=timestamp, + ) + return UpdateSettingsResponse( success=True, message="Configuration updated successfully", @@ -437,14 +533,16 @@ async def check_initial_setup() -> CheckInitialSetupResponse: has_completed_setup = (setup_completed_str or "false").lower() in ("true", "1", "yes") # Determine if setup is needed - # Setup is required if user hasn't completed setup AND there are no models configured - needs_setup = not has_completed_setup and not has_models + # IMPORTANT: Setup is required if there are no models configured, + # regardless of has_completed_setup status. + # This ensures that if user deletes their models/config, they'll see the setup again. + needs_setup = not has_models logger.debug( f"Initial setup check: has_models={has_models}, " f"has_active_model={has_active_model}, " f"has_completed_setup={has_completed_setup}, " - f"needs_setup={needs_setup}" + f"needs_setup={needs_setup} (always true when no models)" ) return CheckInitialSetupResponse( diff --git a/backend/llm/focus_evaluator.py b/backend/llm/focus_evaluator.py new file mode 100644 index 0000000..be692bf --- /dev/null +++ b/backend/llm/focus_evaluator.py @@ -0,0 +1,529 @@ +""" +LLM-based Focus Score Evaluator + +Provides intelligent focus score evaluation using LLM instead of hardcoded rules. +""" + +import json +from datetime import datetime +from typing import Any, Dict, List, Optional + +from core.logger import get_logger + +from .manager import get_llm_manager +from .prompt_manager import get_prompt_manager + +logger = get_logger(__name__) + + +class FocusEvaluator: + """LLM-based focus score evaluator""" + + def __init__(self): + self.llm_manager = get_llm_manager() + self.prompt_manager = get_prompt_manager() + + def _format_activities_detail(self, activities: List[Dict[str, Any]]) -> str: + """ + Format activities into human-readable detail text + + Args: + activities: List of activity dictionaries + + Returns: + Formatted activities detail string + """ + if not activities: + return "No activities recorded" + + lines = [] + for i, activity in enumerate(activities, 1): + title = activity.get("title", "Untitled Activity") + description = activity.get("description", "") + duration = activity.get("session_duration_minutes", 0) + start_time = activity.get("start_time", "") + end_time = activity.get("end_time", "") + topics = activity.get("topic_tags", []) + action_count = len(activity.get("source_action_ids", [])) + + lines.append(f"\n### Activity {i}") + lines.append(f"**Title**: {title}") + if description: + lines.append(f"**Description**: {description}") + lines.append(f"**Time**: {start_time} - {end_time}") + lines.append(f"**Duration**: {duration:.1f} minutes") + lines.append(f"**Action Count**: {action_count}") + if topics: + lines.append(f"**Topics**: {', '.join(topics)}") + + return "\n".join(lines) + + def _collect_all_topics(self, activities: List[Dict[str, Any]]) -> List[str]: + """ + Collect all unique topic tags from activities + + Args: + activities: List of activity dictionaries + + Returns: + List of unique topic tags + """ + all_topics = set() + for activity in activities: + topics = activity.get("topic_tags", []) + all_topics.update(topics) + return sorted(list(all_topics)) + + async def evaluate_focus( + self, + activities: List[Dict[str, Any]], + session_info: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Evaluate focus score using LLM + + Args: + activities: List of activity dictionaries from a work session + session_info: Optional session metadata (start_time, end_time, etc.) + + Returns: + Dictionary containing: + - focus_score: 0-100 integer score + - focus_level: "excellent" | "good" | "moderate" | "low" + - dimension_scores: Dict of 5 dimension scores + - analysis: Dict with strengths, weaknesses, suggestions + - work_type: Type of work + - is_focused_work: Boolean + - distraction_percentage: 0-100 + - deep_work_minutes: Float + - context_summary: String summary + """ + if not activities: + logger.warning("No activities provided for focus evaluation") + return self._get_default_evaluation() + + # Calculate session metadata + total_duration = sum( + activity.get("session_duration_minutes", 0) for activity in activities + ) + activity_count = len(activities) + all_topics = self._collect_all_topics(activities) + + # Determine session time range + if session_info: + start_time = session_info.get("start_time", "") + end_time = session_info.get("end_time", "") + else: + # Extract from activities + start_times = [a.get("start_time") for a in activities if a.get("start_time")] + end_times = [a.get("end_time") for a in activities if a.get("end_time")] + start_time = min(start_times) if start_times else "" + end_time = max(end_times) if end_times else "" + + # Format activities detail + activities_detail = self._format_activities_detail(activities) + + # Get prompt template + try: + user_prompt_template = self.prompt_manager.get_prompt( + "focus_score_evaluation", "user_prompt_template" + ) + system_prompt = self.prompt_manager.get_prompt( + "focus_score_evaluation", "system_prompt" + ) + except Exception as e: + logger.error(f"Failed to load focus evaluation prompts: {e}") + return self._get_default_evaluation() + + # Fill in prompt template + user_prompt = user_prompt_template.format( + start_time=start_time, + end_time=end_time, + total_duration=f"{total_duration:.1f}", + activity_count=activity_count, + topic_tags=", ".join(all_topics) if all_topics else "None", + activities_detail=activities_detail, + ) + + # Call LLM + try: + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + response = await self.llm_manager.chat_completion( + messages, + max_tokens=1500, + temperature=0.3, # Lower temperature for more consistent evaluation + request_type="focus_evaluation", + ) + + content = response.get("content", "") + + # Parse JSON response + evaluation = self._parse_llm_response(content) + + # Validate and normalize the evaluation + evaluation = self._validate_evaluation(evaluation) + + logger.info( + f"LLM focus evaluation completed: score={evaluation.get('focus_score')}, " + f"level={evaluation.get('focus_level')}" + ) + + return evaluation + + except Exception as e: + logger.error(f"LLM focus evaluation failed: {e}", exc_info=True) + return self._get_default_evaluation() + + def _parse_llm_response(self, content: str) -> Dict[str, Any]: + """ + Parse LLM JSON response + + Args: + content: LLM response content + + Returns: + Parsed evaluation dict + """ + # Try to extract JSON from markdown code blocks + if "```json" in content: + start = content.find("```json") + 7 + end = content.find("```", start) + json_str = content[start:end].strip() + elif "```" in content: + start = content.find("```") + 3 + end = content.find("```", start) + json_str = content[start:end].strip() + else: + json_str = content.strip() + + # Parse JSON + try: + evaluation = json.loads(json_str) + return evaluation + except json.JSONDecodeError as e: + logger.error(f"Failed to parse LLM JSON response: {e}\nContent: {content}") + raise + + def _validate_evaluation(self, evaluation: Dict[str, Any]) -> Dict[str, Any]: + """ + Validate and normalize evaluation result + + Args: + evaluation: Raw evaluation dict from LLM + + Returns: + Validated and normalized evaluation dict + """ + # Ensure focus_score is 0-100 integer + focus_score = int(evaluation.get("focus_score", 50)) + focus_score = max(0, min(100, focus_score)) + evaluation["focus_score"] = focus_score + + # Normalize focus_level based on score + score_to_level = { + (80, 100): "excellent", + (60, 79): "good", + (40, 59): "moderate", + (0, 39): "low", + } + for (min_score, max_score), level in score_to_level.items(): + if min_score <= focus_score <= max_score: + evaluation["focus_level"] = level + break + + # Ensure dimension_scores exist and are valid + if "dimension_scores" not in evaluation: + evaluation["dimension_scores"] = {} + + for dimension in [ + "topic_consistency", + "duration_depth", + "switching_rhythm", + "work_quality", + "goal_orientation", + ]: + if dimension not in evaluation["dimension_scores"]: + evaluation["dimension_scores"][dimension] = focus_score + else: + score = int(evaluation["dimension_scores"][dimension]) + evaluation["dimension_scores"][dimension] = max(0, min(100, score)) + + # Ensure analysis structure exists + if "analysis" not in evaluation: + evaluation["analysis"] = {} + + for key in ["strengths", "weaknesses", "suggestions"]: + if key not in evaluation["analysis"]: + evaluation["analysis"][key] = [] + + # Ensure other fields have defaults + # Validate work_type against allowed values + allowed_work_types = { + "development", + "writing", + "learning", + "research", + "design", + "communication", + "entertainment", + "productivity_analysis", + "mixed", + "unclear", + } + work_type = evaluation.get("work_type", "unclear") + if work_type not in allowed_work_types: + logger.warning( + f"Invalid work_type '{work_type}' from LLM, defaulting to 'unclear'. " + f"Allowed values: {allowed_work_types}" + ) + work_type = "unclear" + evaluation["work_type"] = work_type + + evaluation.setdefault("is_focused_work", focus_score >= 60) + evaluation.setdefault("distraction_percentage", max(0, 100 - focus_score)) + evaluation.setdefault("deep_work_minutes", 0) + evaluation.setdefault("context_summary", "") + + return evaluation + + def _get_default_evaluation(self) -> Dict[str, Any]: + """ + Get default evaluation result (used when LLM fails) + + Returns: + Default evaluation dict + """ + return { + "focus_score": 50, + "focus_level": "moderate", + "dimension_scores": { + "topic_consistency": 50, + "duration_depth": 50, + "switching_rhythm": 50, + "work_quality": 50, + "goal_orientation": 50, + }, + "analysis": { + "strengths": [], + "weaknesses": ["Unable to evaluate focus - using default score"], + "suggestions": ["Please ensure LLM is properly configured"], + }, + "work_type": "unclear", + "is_focused_work": False, + "distraction_percentage": 50, + "deep_work_minutes": 0, + "context_summary": "Focus evaluation unavailable", + } + + async def evaluate_activity_focus( + self, activity: Dict[str, Any], session_context: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + Evaluate focus score for a single activity using LLM + + Args: + activity: Single activity dictionary containing title, description, duration, topics, actions + session_context: Optional session context containing user_intent and related_todos + + Returns: + Dictionary containing: + - focus_score: 0-100 integer score + - reasoning: Brief explanation of the score + - work_type: Type of work + - is_productive: Boolean indicating if this is productive work + """ + if not activity: + logger.warning("No activity provided for focus evaluation") + return self._get_default_activity_evaluation() + + # Extract activity information + title = activity.get("title", "Untitled Activity") + description = activity.get("description", "") + duration_minutes = activity.get("session_duration_minutes", 0) + topics = activity.get("topic_tags", []) + actions = activity.get("actions", []) + action_count = len(actions) + + # Format actions summary + actions_summary = self._format_actions_summary(actions) + + # Extract session context + user_intent = "" + related_todos_summary = "" + if session_context: + user_intent = session_context.get("user_intent", "") or "" + related_todos = session_context.get("related_todos", []) + if related_todos: + todos_lines = [] + for todo in related_todos: + todo_title = todo.get("title", "") + todo_desc = todo.get("description", "") + if todo_desc: + todos_lines.append(f"- {todo_title}: {todo_desc}") + else: + todos_lines.append(f"- {todo_title}") + related_todos_summary = "\n".join(todos_lines) + + # Get prompt template + try: + user_prompt_template = self.prompt_manager.get_prompt( + "activity_focus_evaluation", "user_prompt_template" + ) + system_prompt = self.prompt_manager.get_prompt( + "activity_focus_evaluation", "system_prompt" + ) + except Exception as e: + logger.error(f"Failed to load activity focus evaluation prompts: {e}") + return self._get_default_activity_evaluation() + + # Fill in prompt template + user_prompt = user_prompt_template.format( + title=title, + description=description or "No description", + duration_minutes=f"{duration_minutes:.1f}", + topics=", ".join(topics) if topics else "None", + action_count=action_count, + actions_summary=actions_summary, + user_intent=user_intent or "No work goal specified", + related_todos=related_todos_summary or "No related todos", + ) + + # Call LLM + try: + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ] + + response = await self.llm_manager.chat_completion( + messages, + max_tokens=500, + temperature=0.3, # Lower temperature for consistent evaluation + request_type="activity_focus_evaluation", + ) + + content = response.get("content", "") + + # Parse JSON response + evaluation = self._parse_activity_llm_response(content) + + # Validate and normalize the evaluation + evaluation = self._validate_activity_evaluation(evaluation) + + logger.debug( + f"Activity focus evaluation completed: score={evaluation.get('focus_score')}, " + f"activity='{title[:50]}'" + ) + + return evaluation + + except Exception as e: + logger.error(f"LLM activity focus evaluation failed: {e}", exc_info=True) + return self._get_default_activity_evaluation() + + def _format_actions_summary(self, actions: List[Dict[str, Any]]) -> str: + """ + Format actions into a concise summary + + Args: + actions: List of action dictionaries + + Returns: + Formatted actions summary string + """ + if not actions: + return "No actions recorded" + + lines = [] + for i, action in enumerate(actions[:5], 1): # Limit to first 5 actions + title = action.get("title", "Untitled") + lines.append(f"{i}. {title}") + + if len(actions) > 5: + lines.append(f"... and {len(actions) - 5} more actions") + + return "\n".join(lines) + + def _parse_activity_llm_response(self, content: str) -> Dict[str, Any]: + """ + Parse LLM JSON response for activity evaluation + + Args: + content: LLM response content + + Returns: + Parsed evaluation dict + """ + # Try to extract JSON from markdown code blocks + if "```json" in content: + start = content.find("```json") + 7 + end = content.find("```", start) + json_str = content[start:end].strip() + elif "```" in content: + start = content.find("```") + 3 + end = content.find("```", start) + json_str = content[start:end].strip() + else: + json_str = content.strip() + + # Parse JSON + try: + evaluation = json.loads(json_str) + return evaluation + except json.JSONDecodeError as e: + logger.error( + f"Failed to parse activity LLM JSON response: {e}\nContent: {content}" + ) + raise + + def _validate_activity_evaluation(self, evaluation: Dict[str, Any]) -> Dict[str, Any]: + """ + Validate and normalize activity evaluation result + + Args: + evaluation: Raw evaluation dict from LLM + + Returns: + Validated and normalized evaluation dict + """ + # Ensure focus_score is 0-100 integer + focus_score = int(evaluation.get("focus_score", 50)) + focus_score = max(0, min(100, focus_score)) + evaluation["focus_score"] = focus_score + + # Ensure other required fields have defaults + evaluation.setdefault("reasoning", "") + evaluation.setdefault("work_type", "unclear") + evaluation.setdefault("is_productive", focus_score >= 60) + + return evaluation + + def _get_default_activity_evaluation(self) -> Dict[str, Any]: + """ + Get default activity evaluation result (used when LLM fails) + + Returns: + Default evaluation dict + """ + return { + "focus_score": 50, + "reasoning": "Unable to evaluate activity focus - using default score", + "work_type": "unclear", + "is_productive": False, + } + + +# Global instance +_focus_evaluator: Optional[FocusEvaluator] = None + + +def get_focus_evaluator() -> FocusEvaluator: + """Get global FocusEvaluator instance""" + global _focus_evaluator + if _focus_evaluator is None: + _focus_evaluator = FocusEvaluator() + return _focus_evaluator diff --git a/backend/llm/manager.py b/backend/llm/manager.py index ef4617c..85f489b 100644 --- a/backend/llm/manager.py +++ b/backend/llm/manager.py @@ -116,6 +116,51 @@ def force_reload(self): else: logger.debug("No client to reload, will create on next request") + async def health_check(self) -> Dict[str, Any]: + """ + Check if LLM service is available + + Returns: + Dict with 'available' (bool), 'latency_ms' (int), and optional 'error' (str) + """ + import time + + start_time = time.perf_counter() + try: + client = self._ensure_client() + # Use a minimal request to check connectivity + messages = [{"role": "user", "content": "hi"}] + result = await client.chat_completion( + messages=messages, + max_tokens=1, + temperature=0.0, + ) + latency_ms = int((time.perf_counter() - start_time) * 1000) + + # Check if we got a valid response (not an error message) + content = result.get("content", "") + if content and not content.startswith("[Error]"): + return { + "available": True, + "latency_ms": latency_ms, + "model": client.model, + "provider": client.provider, + } + else: + return { + "available": False, + "latency_ms": latency_ms, + "error": content or "Empty response", + } + except Exception as e: + latency_ms = int((time.perf_counter() - start_time) * 1000) + logger.warning(f"LLM health check failed: {e}") + return { + "available": False, + "latency_ms": latency_ms, + "error": str(e), + } + def reload_on_next_request(self): """ Mark client for reload on next request diff --git a/backend/migrations/__init__.py b/backend/migrations/__init__.py new file mode 100644 index 0000000..947e87b --- /dev/null +++ b/backend/migrations/__init__.py @@ -0,0 +1,12 @@ +""" +Database migrations module - Version-based migration system + +This module provides a versioned migration system that: +1. Tracks applied migrations in schema_migrations table +2. Runs migrations in order by version number +3. Supports both SQL-based and Python-based migrations +""" + +from .runner import MigrationRunner + +__all__ = ["MigrationRunner"] diff --git a/backend/migrations/base.py b/backend/migrations/base.py new file mode 100644 index 0000000..00a8e6b --- /dev/null +++ b/backend/migrations/base.py @@ -0,0 +1,51 @@ +""" +Base migration class + +All migrations should inherit from this base class +""" + +import sqlite3 +from abc import ABC, abstractmethod +from typing import Optional + + +class BaseMigration(ABC): + """ + Base class for database migrations + + Each migration must: + 1. Define a unique version string (e.g., "0001", "0002") + 2. Provide a description + 3. Implement the up() method + 4. Optionally implement the down() method for rollbacks + """ + + # Must be overridden in subclass + version: str = "" + description: str = "" + + @abstractmethod + def up(self, cursor: sqlite3.Cursor) -> None: + """ + Execute migration (upgrade database) + + Args: + cursor: SQLite cursor for executing SQL commands + """ + pass + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback migration (downgrade database) + + Args: + cursor: SQLite cursor for executing SQL commands + + Note: + This is optional. Many migrations cannot be safely rolled back. + If not implemented, rollback will be skipped with a warning. + """ + pass + + def __repr__(self) -> str: + return f"" diff --git a/backend/migrations/runner.py b/backend/migrations/runner.py new file mode 100644 index 0000000..04681d9 --- /dev/null +++ b/backend/migrations/runner.py @@ -0,0 +1,265 @@ +""" +Migration runner - Manages database schema versioning + +Responsibilities: +1. Create schema_migrations table if not exists +2. Discover all migration files +3. Determine which migrations need to run +4. Execute migrations in order +5. Record successful migrations +""" + +import importlib +import sqlite3 +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Type + +from core.logger import get_logger + +from .base import BaseMigration + +logger = get_logger(__name__) + + +class MigrationRunner: + """ + Database migration runner with version tracking + + Usage: + runner = MigrationRunner(db_path) + runner.run_migrations() + """ + + SCHEMA_MIGRATIONS_TABLE = """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + description TEXT NOT NULL, + applied_at TEXT NOT NULL + ) + """ + + def __init__(self, db_path: Path): + """ + Initialize migration runner + + Args: + db_path: Path to SQLite database + """ + self.db_path = db_path + self.migrations: Dict[str, Type[BaseMigration]] = {} + + def _ensure_schema_migrations_table(self, cursor: sqlite3.Cursor) -> None: + """ + Create schema_migrations table if it doesn't exist + + Args: + cursor: Database cursor + """ + cursor.execute(self.SCHEMA_MIGRATIONS_TABLE) + logger.debug("✓ schema_migrations table ready") + + def _get_applied_versions(self, cursor: sqlite3.Cursor) -> set: + """ + Get set of already-applied migration versions + + Args: + cursor: Database cursor + + Returns: + Set of version strings + """ + cursor.execute("SELECT version FROM schema_migrations") + rows = cursor.fetchall() + return {row[0] for row in rows} + + def _discover_migrations(self) -> List[Type[BaseMigration]]: + """ + Discover all migration classes from versions directory + + Returns: + List of migration classes sorted by version + """ + migrations_dir = Path(__file__).parent / "versions" + + if not migrations_dir.exists(): + logger.warning(f"Migrations directory not found: {migrations_dir}") + return [] + + discovered = [] + + # Import all Python files in versions directory + for migration_file in sorted(migrations_dir.glob("*.py")): + if migration_file.name.startswith("_"): + continue # Skip __init__.py and other private files + + module_name = f"migrations.versions.{migration_file.stem}" + + try: + module = importlib.import_module(module_name) + + # Find migration class in module + for attr_name in dir(module): + attr = getattr(module, attr_name) + + # Check if it's a migration class + if ( + isinstance(attr, type) + and issubclass(attr, BaseMigration) + and attr is not BaseMigration + ): + discovered.append(attr) + logger.debug(f"Discovered migration: {attr.version} - {attr.description}") + + except Exception as e: + logger.error(f"Failed to load migration {migration_file}: {e}", exc_info=True) + + # Sort by version + discovered.sort(key=lambda m: m.version) + + return discovered + + def _record_migration( + self, cursor: sqlite3.Cursor, migration: BaseMigration + ) -> None: + """ + Record successful migration in schema_migrations table + + Args: + cursor: Database cursor + migration: Migration instance + """ + cursor.execute( + """ + INSERT INTO schema_migrations (version, description, applied_at) + VALUES (?, ?, ?) + """, + ( + migration.version, + migration.description, + datetime.now().isoformat(), + ), + ) + logger.info(f"✓ Recorded migration: {migration.version}") + + def run_migrations(self) -> int: + """ + Run all pending migrations + + Returns: + Number of migrations executed + """ + try: + conn = sqlite3.connect(str(self.db_path)) + cursor = conn.cursor() + + # Ensure tracking table exists + self._ensure_schema_migrations_table(cursor) + conn.commit() + + # Get applied versions + applied_versions = self._get_applied_versions(cursor) + logger.debug(f"Applied migrations: {applied_versions}") + + # Discover all migrations + all_migrations = self._discover_migrations() + + if not all_migrations: + logger.info("No migrations found") + conn.close() + return 0 + + # Filter to pending migrations + pending_migrations = [ + m for m in all_migrations if m.version not in applied_versions + ] + + if not pending_migrations: + logger.info("✓ All migrations up to date") + conn.close() + return 0 + + logger.info(f"Found {len(pending_migrations)} pending migration(s)") + + # Execute each pending migration + executed_count = 0 + for migration_class in pending_migrations: + migration = migration_class() + + logger.info(f"Running migration {migration.version}: {migration.description}") + + try: + # Execute migration + migration.up(cursor) + + # Record success + self._record_migration(cursor, migration) + conn.commit() + + executed_count += 1 + logger.info(f"✓ Migration {migration.version} completed successfully") + + except Exception as e: + logger.error( + f"✗ Migration {migration.version} failed: {e}", + exc_info=True, + ) + conn.rollback() + raise + + conn.close() + + logger.info(f"✓ Successfully executed {executed_count} migration(s)") + return executed_count + + except Exception as e: + logger.error(f"Migration runner failed: {e}", exc_info=True) + raise + + def get_migration_status(self) -> Dict[str, Any]: + """ + Get current migration status + + Returns: + Dictionary with migration status information + """ + try: + conn = sqlite3.connect(str(self.db_path)) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + + # Ensure tracking table exists + self._ensure_schema_migrations_table(cursor) + + # Get applied migrations + cursor.execute( + """ + SELECT version, description, applied_at + FROM schema_migrations + ORDER BY version + """ + ) + applied = [dict(row) for row in cursor.fetchall()] + + # Discover all migrations + all_migrations = self._discover_migrations() + + applied_versions = {m["version"] for m in applied} + pending = [ + {"version": m.version, "description": m.description} + for m in all_migrations + if m.version not in applied_versions + ] + + conn.close() + + return { + "applied_count": len(applied), + "pending_count": len(pending), + "applied": applied, + "pending": pending, + } + + except Exception as e: + logger.error(f"Failed to get migration status: {e}", exc_info=True) + raise diff --git a/backend/migrations/versions/0001_initial_schema.py b/backend/migrations/versions/0001_initial_schema.py new file mode 100644 index 0000000..c1c730c --- /dev/null +++ b/backend/migrations/versions/0001_initial_schema.py @@ -0,0 +1,33 @@ +""" +Migration 0001: Initial database schema + +Creates all base tables and indexes for the iDO application +""" + +import sqlite3 + +from migrations.base import BaseMigration + + +class Migration(BaseMigration): + version = "0001" + description = "Initial database schema with all base tables" + + def up(self, cursor: sqlite3.Cursor) -> None: + """Create all initial tables and indexes""" + from core.sqls import schema + + # Create all tables + for table_sql in schema.ALL_TABLES: + cursor.execute(table_sql) + + # Create all indexes + for index_sql in schema.ALL_INDEXES: + cursor.execute(index_sql) + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback not supported for initial schema + Would require dropping all tables + """ + pass diff --git a/backend/migrations/versions/0002_add_knowledge_actions_columns.py b/backend/migrations/versions/0002_add_knowledge_actions_columns.py new file mode 100644 index 0000000..27a8f45 --- /dev/null +++ b/backend/migrations/versions/0002_add_knowledge_actions_columns.py @@ -0,0 +1,53 @@ +""" +Migration 0002: Add knowledge extraction columns to actions table + +Adds columns to support knowledge extraction feature +""" + +import sqlite3 + +from migrations.base import BaseMigration + + +class Migration(BaseMigration): + version = "0002" + description = "Add knowledge extraction columns to actions and knowledge tables" + + def up(self, cursor: sqlite3.Cursor) -> None: + """Add columns for knowledge extraction feature""" + + # List of column additions (with error handling for already-exists) + columns_to_add = [ + ( + "actions", + "extract_knowledge", + "ALTER TABLE actions ADD COLUMN extract_knowledge BOOLEAN DEFAULT 0", + ), + ( + "actions", + "knowledge_extracted", + "ALTER TABLE actions ADD COLUMN knowledge_extracted BOOLEAN DEFAULT 0", + ), + ( + "knowledge", + "source_action_id", + "ALTER TABLE knowledge ADD COLUMN source_action_id TEXT", + ), + ] + + for table, column, sql in columns_to_add: + try: + cursor.execute(sql) + except sqlite3.OperationalError as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg or "already exists" in error_msg: + # Column already exists, skip + pass + else: + raise + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback not supported (SQLite doesn't support DROP COLUMN in older versions) + """ + pass diff --git a/backend/migrations/versions/0003_add_pomodoro_feature.py b/backend/migrations/versions/0003_add_pomodoro_feature.py new file mode 100644 index 0000000..97534b2 --- /dev/null +++ b/backend/migrations/versions/0003_add_pomodoro_feature.py @@ -0,0 +1,125 @@ +""" +Migration 0003: Add Pomodoro feature + +Adds columns to existing tables for Pomodoro session tracking: +- pomodoro_session_id to raw_records, actions, events, activities +- user_intent and pomodoro_status to activities + +Also creates indexes for efficient querying +""" + +import sqlite3 + +from migrations.base import BaseMigration + + +class Migration(BaseMigration): + version = "0003" + description = "Add Pomodoro feature columns and indexes" + + def up(self, cursor: sqlite3.Cursor) -> None: + """Add Pomodoro-related columns and indexes""" + + # Column additions + columns_to_add = [ + ( + "raw_records", + "pomodoro_session_id", + "ALTER TABLE raw_records ADD COLUMN pomodoro_session_id TEXT", + ), + ( + "actions", + "pomodoro_session_id", + "ALTER TABLE actions ADD COLUMN pomodoro_session_id TEXT", + ), + ( + "events", + "pomodoro_session_id", + "ALTER TABLE events ADD COLUMN pomodoro_session_id TEXT", + ), + ( + "activities", + "pomodoro_session_id", + "ALTER TABLE activities ADD COLUMN pomodoro_session_id TEXT", + ), + ( + "activities", + "user_intent", + "ALTER TABLE activities ADD COLUMN user_intent TEXT", + ), + ( + "activities", + "pomodoro_status", + "ALTER TABLE activities ADD COLUMN pomodoro_status TEXT", + ), + ] + + for table, column, sql in columns_to_add: + try: + cursor.execute(sql) + except sqlite3.OperationalError as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg or "already exists" in error_msg: + # Column already exists, skip + pass + else: + raise + + # Index creation + indexes_to_create = [ + ( + "idx_raw_records_pomodoro_session", + """ + CREATE INDEX IF NOT EXISTS idx_raw_records_pomodoro_session + ON raw_records(pomodoro_session_id) + """, + ), + ( + "idx_actions_pomodoro_session", + """ + CREATE INDEX IF NOT EXISTS idx_actions_pomodoro_session + ON actions(pomodoro_session_id) + """, + ), + ( + "idx_events_pomodoro_session", + """ + CREATE INDEX IF NOT EXISTS idx_events_pomodoro_session + ON events(pomodoro_session_id) + """, + ), + ( + "idx_activities_pomodoro_session", + """ + CREATE INDEX IF NOT EXISTS idx_activities_pomodoro_session + ON activities(pomodoro_session_id) + """, + ), + ( + "idx_activities_pomodoro_status", + """ + CREATE INDEX IF NOT EXISTS idx_activities_pomodoro_status + ON activities(pomodoro_status) + """, + ), + ] + + for index_name, sql in indexes_to_create: + try: + cursor.execute(sql) + except Exception as e: + # Index creation failures are usually safe to ignore + # (index might already exist) + pass + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback not supported (SQLite doesn't support DROP COLUMN easily) + + To rollback, you would need to: + 1. Create new tables without the columns + 2. Copy data + 3. Drop old tables + 4. Rename new tables + """ + pass diff --git a/backend/migrations/versions/0004_add_pomodoro_todo_ratings.py b/backend/migrations/versions/0004_add_pomodoro_todo_ratings.py new file mode 100644 index 0000000..5d7162c --- /dev/null +++ b/backend/migrations/versions/0004_add_pomodoro_todo_ratings.py @@ -0,0 +1,107 @@ +""" +Migration 0004: Add Pomodoro-TODO association and Activity ratings + +Changes: +1. Add associated_todo_id column to pomodoro_sessions table +2. Create activity_ratings table for multi-dimensional activity ratings +3. Add indexes for efficient querying +""" + +import sqlite3 + +from migrations.base import BaseMigration + + +class Migration(BaseMigration): + version = "0004" + description = "Add Pomodoro-TODO association and Activity ratings" + + def up(self, cursor: sqlite3.Cursor) -> None: + """Add Pomodoro-TODO association and activity ratings tables""" + + # 1. Add associated_todo_id column to pomodoro_sessions + try: + cursor.execute( + """ + ALTER TABLE pomodoro_sessions + ADD COLUMN associated_todo_id TEXT + """ + ) + except sqlite3.OperationalError as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg or "already exists" in error_msg: + # Column already exists, skip + pass + else: + raise + + # 2. Create index for associated_todo_id + try: + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_todo + ON pomodoro_sessions(associated_todo_id) + """ + ) + except Exception: + # Index creation failures are usually safe to ignore + pass + + # 3. Create activity_ratings table + try: + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS activity_ratings ( + id TEXT PRIMARY KEY, + activity_id TEXT NOT NULL, + dimension TEXT NOT NULL, + rating INTEGER NOT NULL CHECK(rating >= 1 AND rating <= 5), + note TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE, + UNIQUE(activity_id, dimension) + ) + """ + ) + except Exception as e: + # Table might already exist + pass + + # 4. Create indexes for activity_ratings + indexes_to_create = [ + ( + "idx_activity_ratings_activity", + """ + CREATE INDEX IF NOT EXISTS idx_activity_ratings_activity + ON activity_ratings(activity_id) + """ + ), + ( + "idx_activity_ratings_dimension", + """ + CREATE INDEX IF NOT EXISTS idx_activity_ratings_dimension + ON activity_ratings(dimension) + """ + ), + ] + + for index_name, sql in indexes_to_create: + try: + cursor.execute(sql) + except Exception: + # Index creation failures are usually safe to ignore + pass + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback not supported (SQLite doesn't support DROP COLUMN easily) + + To rollback, you would need to: + 1. Drop activity_ratings table + 2. Create new pomodoro_sessions table without associated_todo_id + 3. Copy data + 4. Drop old pomodoro_sessions table + 5. Rename new table + """ + pass diff --git a/backend/migrations/versions/0005_add_pomodoro_rounds.py b/backend/migrations/versions/0005_add_pomodoro_rounds.py new file mode 100644 index 0000000..d80f60e --- /dev/null +++ b/backend/migrations/versions/0005_add_pomodoro_rounds.py @@ -0,0 +1,143 @@ +""" +Migration 0005: Add Pomodoro rounds and phase management + +Adds support for multi-round Pomodoro sessions with work/break phases: +- work_duration_minutes: Duration of work phase (default 25) +- break_duration_minutes: Duration of break phase (default 5) +- total_rounds: Total number of work rounds to complete (default 4) +- current_round: Current round number (1-based) +- current_phase: Current phase (work/break/completed) +- phase_start_time: When current phase started +- completed_rounds: Number of completed work rounds +""" + +import sqlite3 + +from migrations.base import BaseMigration + + +class Migration(BaseMigration): + version = "0005" + description = "Add Pomodoro rounds and phase management" + + def up(self, cursor: sqlite3.Cursor) -> None: + """Add Pomodoro rounds-related columns and work phases table""" + + # Create pomodoro_work_phases table + try: + cursor.execute( + """ + CREATE TABLE IF NOT EXISTS pomodoro_work_phases ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + phase_number INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + processing_error TEXT, + retry_count INTEGER DEFAULT 0, + phase_start_time TEXT NOT NULL, + phase_end_time TEXT, + activity_count INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES pomodoro_sessions(id) ON DELETE CASCADE, + CHECK(status IN ('pending', 'processing', 'completed', 'failed')), + UNIQUE(session_id, phase_number) + ) + """ + ) + except Exception: + # Table might already exist + pass + + # Column additions for round management + columns_to_add = [ + ( + "pomodoro_sessions", + "work_duration_minutes", + "ALTER TABLE pomodoro_sessions ADD COLUMN work_duration_minutes INTEGER DEFAULT 25", + ), + ( + "pomodoro_sessions", + "break_duration_minutes", + "ALTER TABLE pomodoro_sessions ADD COLUMN break_duration_minutes INTEGER DEFAULT 5", + ), + ( + "pomodoro_sessions", + "total_rounds", + "ALTER TABLE pomodoro_sessions ADD COLUMN total_rounds INTEGER DEFAULT 4", + ), + ( + "pomodoro_sessions", + "current_round", + "ALTER TABLE pomodoro_sessions ADD COLUMN current_round INTEGER DEFAULT 1", + ), + ( + "pomodoro_sessions", + "current_phase", + "ALTER TABLE pomodoro_sessions ADD COLUMN current_phase TEXT DEFAULT 'work'", + ), + ( + "pomodoro_sessions", + "phase_start_time", + "ALTER TABLE pomodoro_sessions ADD COLUMN phase_start_time TEXT", + ), + ( + "pomodoro_sessions", + "completed_rounds", + "ALTER TABLE pomodoro_sessions ADD COLUMN completed_rounds INTEGER DEFAULT 0", + ), + ] + + for table, column, sql in columns_to_add: + try: + cursor.execute(sql) + except sqlite3.OperationalError as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg or "already exists" in error_msg: + # Column already exists, skip + pass + else: + raise + + # Add index for current_phase for efficient querying + try: + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_pomodoro_sessions_phase + ON pomodoro_sessions(current_phase) + """ + ) + except Exception: + # Index creation failures are usually safe to ignore + pass + + # Create indexes for pomodoro_work_phases table + indexes_to_create = [ + """ + CREATE INDEX IF NOT EXISTS idx_work_phases_session + ON pomodoro_work_phases(session_id, phase_number) + """, + """ + CREATE INDEX IF NOT EXISTS idx_work_phases_status + ON pomodoro_work_phases(status) + """, + ] + + for index_sql in indexes_to_create: + try: + cursor.execute(index_sql) + except Exception: + # Index creation failures are usually safe to ignore + pass + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback not supported (SQLite doesn't support DROP COLUMN easily) + + To rollback, you would need to: + 1. Create new pomodoro_sessions table without the new columns + 2. Copy data + 3. Drop old table + 4. Rename new table + """ + pass diff --git a/backend/migrations/versions/0006_add_activity_work_phase_tracking.py b/backend/migrations/versions/0006_add_activity_work_phase_tracking.py new file mode 100644 index 0000000..1519568 --- /dev/null +++ b/backend/migrations/versions/0006_add_activity_work_phase_tracking.py @@ -0,0 +1,70 @@ +""" +Migration 0006: Add work phase tracking and focus score to activities + +Adds columns to activities table for better Pomodoro session tracking: +- pomodoro_work_phase: Track which work round (1-4) generated the activity +- focus_score: Pre-calculated focus metric (0.0-1.0) for each activity + +Also creates index for efficient querying by session and work phase +""" + +import sqlite3 + +from migrations.base import BaseMigration + + +class Migration(BaseMigration): + version = "0006" + description = "Add work phase tracking and focus score to activities" + + def up(self, cursor: sqlite3.Cursor) -> None: + """Add work phase and focus score columns to activities table""" + + # Column additions + columns_to_add = [ + ( + "activities", + "pomodoro_work_phase", + "ALTER TABLE activities ADD COLUMN pomodoro_work_phase INTEGER", + ), + ( + "activities", + "focus_score", + "ALTER TABLE activities ADD COLUMN focus_score REAL DEFAULT 0.5", + ), + ] + + for table, column, sql in columns_to_add: + try: + cursor.execute(sql) + except sqlite3.OperationalError as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg or "already exists" in error_msg: + # Column already exists, skip + pass + else: + raise + + # Index creation for efficient querying + index_sql = """ + CREATE INDEX IF NOT EXISTS idx_activities_pomodoro_work_phase + ON activities(pomodoro_session_id, pomodoro_work_phase) + """ + try: + cursor.execute(index_sql) + except Exception: + # Index creation failures are usually safe to ignore + # (index might already exist) + pass + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback not supported (SQLite doesn't support DROP COLUMN easily) + + To rollback, you would need to: + 1. Create new table without the columns + 2. Copy data + 3. Drop old table + 4. Rename new table + """ + pass diff --git a/backend/migrations/versions/0007_add_action_based_aggregation.py b/backend/migrations/versions/0007_add_action_based_aggregation.py new file mode 100644 index 0000000..abeebe2 --- /dev/null +++ b/backend/migrations/versions/0007_add_action_based_aggregation.py @@ -0,0 +1,83 @@ +""" +Migration 0007: Add action-based aggregation support to activities + +Adds columns to activities table to support direct action→activity aggregation: +- source_action_ids: JSON array of action IDs (alternative to source_event_ids) +- aggregation_mode: Flag to indicate 'event_based' or 'action_based' aggregation + +This migration enables the unified action-based architecture where both Normal Mode +and Pomodoro Mode can aggregate actions directly into activities, bypassing the +Events layer for better temporal continuity. +""" + +import sqlite3 + +from migrations.base import BaseMigration + + +class Migration(BaseMigration): + version = "0007" + description = "Add action-based aggregation support to activities" + + def up(self, cursor: sqlite3.Cursor) -> None: + """Add action-based aggregation columns to activities table""" + + # Column additions + columns_to_add = [ + ( + "activities", + "source_action_ids", + "ALTER TABLE activities ADD COLUMN source_action_ids TEXT", + ), + ( + "activities", + "aggregation_mode", + "ALTER TABLE activities ADD COLUMN aggregation_mode TEXT DEFAULT 'action_based'", + ), + ] + + for table, column, sql in columns_to_add: + try: + cursor.execute(sql) + except sqlite3.OperationalError as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg or "already exists" in error_msg: + # Column already exists, skip + pass + else: + raise + + # Set existing activities to event_based mode (they have source_event_ids) + update_sql = """ + UPDATE activities + SET aggregation_mode = 'event_based' + WHERE aggregation_mode IS NULL AND source_event_ids IS NOT NULL + """ + try: + cursor.execute(update_sql) + except Exception: + # If update fails, it's not critical - new activities will default to action_based + pass + + # Add index for aggregation_mode for efficient querying + index_sql = """ + CREATE INDEX IF NOT EXISTS idx_activities_aggregation_mode + ON activities(aggregation_mode) + """ + try: + cursor.execute(index_sql) + except Exception: + # Index creation failures are usually safe to ignore + pass + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback not supported (SQLite doesn't support DROP COLUMN easily) + + To rollback, you would need to: + 1. Create new table without the columns + 2. Copy data + 3. Drop old table + 4. Rename new table + """ + pass diff --git a/backend/migrations/versions/0008_add_knowledge_favorite.py b/backend/migrations/versions/0008_add_knowledge_favorite.py new file mode 100644 index 0000000..f1af7ab --- /dev/null +++ b/backend/migrations/versions/0008_add_knowledge_favorite.py @@ -0,0 +1,48 @@ +""" +Migration 0008: Add favorite column to knowledge table + +Adds favorite column to support favoriting knowledge items +""" + +import sqlite3 + +from migrations.base import BaseMigration + + +class Migration(BaseMigration): + version = "0008" + description = "Add favorite column to knowledge table" + + def up(self, cursor: sqlite3.Cursor) -> None: + """Add favorite column to knowledge table""" + + # Add favorite column + try: + cursor.execute( + "ALTER TABLE knowledge ADD COLUMN favorite BOOLEAN DEFAULT 0" + ) + except sqlite3.OperationalError as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg or "already exists" in error_msg: + # Column already exists, skip + pass + else: + raise + + # Create index for favorite column + try: + cursor.execute( + """ + CREATE INDEX IF NOT EXISTS idx_knowledge_favorite + ON knowledge(favorite) + """ + ) + except sqlite3.OperationalError: + # Index might already exist, ignore + pass + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback not supported (SQLite doesn't support DROP COLUMN in older versions) + """ + pass diff --git a/backend/migrations/versions/0009_add_llm_evaluation.py b/backend/migrations/versions/0009_add_llm_evaluation.py new file mode 100644 index 0000000..fc2a71a --- /dev/null +++ b/backend/migrations/versions/0009_add_llm_evaluation.py @@ -0,0 +1,56 @@ +""" +Migration 0009: Add LLM evaluation fields to pomodoro_sessions + +Adds llm_evaluation_result and llm_evaluation_computed_at columns +to support caching LLM focus evaluations +""" + +import sqlite3 + +from migrations.base import BaseMigration + + +class Migration(BaseMigration): + version = "0009" + description = "Add LLM evaluation fields to pomodoro_sessions" + + def up(self, cursor: sqlite3.Cursor) -> None: + """Add LLM evaluation columns to pomodoro_sessions table""" + + # Add llm_evaluation_result column + try: + cursor.execute( + """ + ALTER TABLE pomodoro_sessions + ADD COLUMN llm_evaluation_result TEXT + """ + ) + except sqlite3.OperationalError as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg or "already exists" in error_msg: + # Column already exists, skip + pass + else: + raise + + # Add llm_evaluation_computed_at column + try: + cursor.execute( + """ + ALTER TABLE pomodoro_sessions + ADD COLUMN llm_evaluation_computed_at TEXT + """ + ) + except sqlite3.OperationalError as e: + error_msg = str(e).lower() + if "duplicate column" in error_msg or "already exists" in error_msg: + # Column already exists, skip + pass + else: + raise + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback not supported (SQLite doesn't support DROP COLUMN in older versions) + """ + pass diff --git a/backend/migrations/versions/0010_simplify_pomodoro_status.py b/backend/migrations/versions/0010_simplify_pomodoro_status.py new file mode 100644 index 0000000..e3e10df --- /dev/null +++ b/backend/migrations/versions/0010_simplify_pomodoro_status.py @@ -0,0 +1,45 @@ +""" +Migration 0010: Simplify Pomodoro status values + +Simplifies status and processing_status values by merging similar states: +- status: 'interrupted' and 'too_short' → 'abandoned' +- processing_status: 'skipped' → 'failed' + +This reduces state complexity from 10 combinations to 7 combinations. +""" + +import sqlite3 + +from migrations.base import BaseMigration + + +class Migration(BaseMigration): + version = "0010" + description = "Simplify Pomodoro status values" + + def up(self, cursor: sqlite3.Cursor) -> None: + """Merge similar status values to simplify state management""" + + # Merge 'interrupted' and 'too_short' into 'abandoned' + cursor.execute( + """ + UPDATE pomodoro_sessions + SET status = 'abandoned' + WHERE status IN ('interrupted', 'too_short') + """ + ) + + # Merge 'skipped' into 'failed' + cursor.execute( + """ + UPDATE pomodoro_sessions + SET processing_status = 'failed' + WHERE processing_status = 'skipped' + """ + ) + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback not supported - cannot reliably restore original status values + """ + pass diff --git a/backend/migrations/versions/0011_add_todo_expiration.py b/backend/migrations/versions/0011_add_todo_expiration.py new file mode 100644 index 0000000..3003fa2 --- /dev/null +++ b/backend/migrations/versions/0011_add_todo_expiration.py @@ -0,0 +1,49 @@ +""" +Migration 0011: Add todo expiration and source tracking + +Adds expiration and source tracking columns to todos table: +- expires_at: Expiration timestamp for AI-generated todos (default 3 days) +- source_type: 'ai' or 'manual' to track todo origin + +This enables automatic cleanup of expired AI-generated todos. +""" + +import sqlite3 + +from migrations.base import BaseMigration + + +class Migration(BaseMigration): + version = "0011" + description = "Add todo expiration and source tracking" + + def up(self, cursor: sqlite3.Cursor) -> None: + """Add expires_at and source_type columns to todos table""" + + # Add expires_at column (NULL means no expiration) + cursor.execute( + """ + ALTER TABLE todos ADD COLUMN expires_at TEXT + """ + ) + + # Add source_type column (default 'ai' for existing records) + cursor.execute( + """ + ALTER TABLE todos ADD COLUMN source_type TEXT DEFAULT 'ai' + """ + + ) + + # Update existing records to have source_type = 'ai' + cursor.execute( + """ + UPDATE todos SET source_type = 'ai' WHERE source_type IS NULL + """ + ) + + def down(self, cursor: sqlite3.Cursor) -> None: + """ + Rollback not supported - cannot reliably restore original schema + """ + pass diff --git a/backend/migrations/versions/__init__.py b/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..54f45bb --- /dev/null +++ b/backend/migrations/versions/__init__.py @@ -0,0 +1,11 @@ +""" +Migration versions directory + +Each migration file should be named: XXXX_description.py +Where XXXX is a 4-digit version number (e.g., 0001, 0002, etc.) + +Example: + 0001_initial_schema.py + 0002_add_three_layer_architecture.py + 0003_add_pomodoro_feature.py +""" diff --git a/backend/models/requests.py b/backend/models/requests.py index 78f666c..141833d 100644 --- a/backend/models/requests.py +++ b/backend/models/requests.py @@ -4,7 +4,7 @@ """ from datetime import datetime -from typing import List, Optional +from typing import List, Literal, Optional from pydantic import Field @@ -252,11 +252,37 @@ class UpdateSettingsRequest(BaseModel): @property databasePath - Path to the database file (optional). @property screenshotSavePath - Path to save screenshots (optional). @property language - Application language (zh or en) (optional). + @property fontSize - Application font size (small, default, large, extra-large) (optional). + @property voiceEnabled - Enable voice reminders (optional). + @property voiceVolume - Voice volume (0.0-1.0) (optional). + @property voiceLanguage - Voice language (zh-CN or en-US) (optional). + @property voiceId - Voice ID (optional). + @property clockEnabled - Enable desktop clock (optional). + @property clockPosition - Clock position (bottom-right, bottom-left, top-right, top-left) (optional). + @property clockSize - Clock size (small, medium, large) (optional). + @property clockCustomX - Custom X position in screen coordinates (optional). + @property clockCustomY - Custom Y position in screen coordinates (optional). + @property clockCustomWidth - Custom window width (optional). + @property clockCustomHeight - Custom window height (optional). + @property clockUseCustomPosition - Whether to use custom position instead of preset position (optional). """ database_path: Optional[str] = None screenshot_save_path: Optional[str] = None language: Optional[str] = None + font_size: Optional[str] = None + voice_enabled: Optional[bool] = None + voice_volume: Optional[float] = None + voice_sound_theme: Optional[str] = None + voice_custom_sounds: Optional[dict] = None + clock_enabled: Optional[bool] = None + clock_position: Optional[str] = None + clock_size: Optional[str] = None + clock_custom_x: Optional[int] = None + clock_custom_y: Optional[int] = None + clock_custom_width: Optional[int] = None + clock_custom_height: Optional[int] = None + clock_use_custom_position: Optional[bool] = None class UpdateLive2DSettingsRequest(BaseModel): @@ -620,6 +646,17 @@ class ReadImageFileRequest(BaseModel): file_path: str +class CleanupBrokenActionsRequest(BaseModel): + """Request parameters for cleaning up actions with missing images. + + @property strategy - Cleanup strategy: delete_actions, remove_references, or dry_run. + @property actionIds - Optional list of specific action IDs to process. + """ + + strategy: Literal["delete_actions", "remove_references", "dry_run"] + action_ids: Optional[List[str]] = None + + # ============================================================================ # Three-Layer Architecture Request Models (Activities → Events → Actions) # ============================================================================ @@ -822,3 +859,105 @@ class DeleteDiariesByDateRequest(BaseModel): start_date: str = Field(..., pattern=r"^\d{4}-\d{2}-\d{2}$") end_date: str = Field(..., pattern=r"^\d{4}-\d{2}-\d{2}$") + + +class ToggleKnowledgeFavoriteRequest(BaseModel): + """Request parameters for toggling knowledge favorite status. + + @property id - Knowledge ID to toggle favorite status + """ + + id: str + + +class CreateKnowledgeRequest(BaseModel): + """Request parameters for manually creating knowledge. + + @property title - Knowledge title + @property description - Knowledge description + @property keywords - List of keywords/tags + """ + + title: str = Field(..., min_length=1, max_length=500) + description: str = Field(..., min_length=1) + keywords: List[str] = Field(default_factory=list) + + +class UpdateKnowledgeRequest(BaseModel): + """Request parameters for updating knowledge. + + @property id - Knowledge ID to update + @property title - Knowledge title + @property description - Knowledge description + @property keywords - List of keywords/tags + """ + + id: str + title: str = Field(..., min_length=1, max_length=500) + description: str = Field(..., min_length=1) + keywords: List[str] = Field(default_factory=list) + + +class AnalyzeKnowledgeMergeRequest(BaseModel): + """Request parameters for analyzing knowledge similarity and generating merge suggestions. + + @property filter_by_keyword - Only analyze knowledge with this keyword (None = all) + @property include_favorites - Whether to include favorite knowledge in analysis + @property similarity_threshold - Similarity threshold for merging (0.0-1.0) + """ + + filter_by_keyword: Optional[str] = None + include_favorites: bool = True + similarity_threshold: float = Field(default=0.7, ge=0.0, le=1.0) + + +class MergeGroup(BaseModel): + """Represents a user-confirmed merge group. + + @property group_id - Unique identifier for this merge group + @property knowledge_ids - List of knowledge IDs to merge + @property merged_title - Title for the merged knowledge + @property merged_description - Description for the merged knowledge + @property merged_keywords - Keywords for the merged knowledge + @property merge_reason - Optional reason for merging + @property keep_favorite - Whether to keep favorite status if any source is favorite + """ + + group_id: str + knowledge_ids: List[str] + merged_title: str = Field(..., min_length=1, max_length=500) + merged_description: str = Field(..., min_length=1) + merged_keywords: List[str] = Field(default_factory=list) + merge_reason: Optional[str] = None + keep_favorite: bool = True + + +class ExecuteKnowledgeMergeRequest(BaseModel): + """Request parameters for executing approved knowledge merge operations. + + @property merge_groups - List of merge groups to execute + """ + + merge_groups: List[MergeGroup] + + +# ============ Todo Requests ============ + + +class CreateTodoRequest(BaseModel): + """Request parameters for manually creating a todo. + + @property title - Todo title (required) + @property description - Todo description (required) + @property keywords - List of keywords/tags (optional) + @property scheduled_date - Optional scheduled date (YYYY-MM-DD format) + @property scheduled_time - Optional scheduled time (HH:MM format) + @property scheduled_end_time - Optional scheduled end time (HH:MM format) + """ + + title: str = Field(..., min_length=1, max_length=500) + description: str = Field(..., min_length=1) + keywords: List[str] = Field(default_factory=list) + scheduled_date: Optional[str] = Field(None, pattern=r"^\d{4}-\d{2}-\d{2}$") + scheduled_time: Optional[str] = Field(None, pattern=r"^\d{2}:\d{2}$") + scheduled_end_time: Optional[str] = Field(None, pattern=r"^\d{2}:\d{2}$") diff --git a/backend/models/responses.py b/backend/models/responses.py index ca232c2..444f444 100644 --- a/backend/models/responses.py +++ b/backend/models/responses.py @@ -3,7 +3,7 @@ Provides strongly typed response models for better type safety and auto-generation """ -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Literal, Optional from models.base import BaseModel, OperationResponse, TimedOperationResponse @@ -197,6 +197,39 @@ class ImageOptimizationStatsResponse(OperationResponse): config: Optional[Dict[str, Any]] = None +class ImagePersistenceHealthData(BaseModel): + """Data model for image persistence health check""" + + total_actions: int + actions_with_screenshots: int + actions_all_images_ok: int + actions_partial_missing: int + actions_all_missing: int + total_image_references: int + images_found: int + images_missing: int + missing_rate_percent: float + memory_cache_current_size: int + memory_cache_max_size: int + memory_ttl_seconds: int + actions_with_issues: List[Dict[str, Any]] + + +class ImagePersistenceHealthResponse(OperationResponse): + """Response containing image persistence health check results""" + + data: Optional[ImagePersistenceHealthData] = None + + +class CleanupBrokenActionsResponse(OperationResponse): + """Response after cleaning up broken action images""" + + actions_processed: int = 0 + actions_deleted: int = 0 + references_removed: int = 0 + images_removed: int = 0 + + class UpdateImageOptimizationConfigResponse(OperationResponse): """Response after updating image optimization configuration""" @@ -255,6 +288,9 @@ class SettingsInfoData(BaseModel): database: Dict[str, str] screenshot: Dict[str, str] language: str + font_size: str = "default" + voice: Optional[Dict[str, Any]] = None + clock: Optional[Dict[str, Any]] = None image: Dict[str, Any] @@ -343,3 +379,311 @@ class CompleteInitialSetupResponse(TimedOperationResponse): pass +# Pomodoro Feature Response Models +class PomodoroSessionData(BaseModel): + """Pomodoro session data with rounds support""" + + session_id: str + user_intent: str + start_time: str + elapsed_minutes: int + planned_duration_minutes: int + associated_todo_id: Optional[str] = None + associated_todo_title: Optional[str] = None + # Rounds configuration + work_duration_minutes: int = 25 + break_duration_minutes: int = 5 + total_rounds: int = 4 + current_round: int = 1 + current_phase: Literal["work", "break", "completed"] = "work" + phase_start_time: Optional[str] = None + completed_rounds: int = 0 + # Calculated fields for frontend + remaining_phase_seconds: Optional[int] = None + pure_work_duration_minutes: int = 0 # completed_rounds × work_duration_minutes (excludes breaks) + + +class StartPomodoroResponse(TimedOperationResponse): + """Response after starting a Pomodoro session""" + + data: Optional[PomodoroSessionData] = None + + +class EndPomodoroData(BaseModel): + """End Pomodoro session result data""" + + session_id: str + status: str # Session status (completed, abandoned, etc.) + actual_work_minutes: int # Actual work duration in minutes + raw_records_count: int = 0 # Number of raw records captured + processing_job_id: Optional[str] = None # Deprecated, always None now + message: str = "" # Optional message for user + + +class EndPomodoroResponse(TimedOperationResponse): + """Response after ending a Pomodoro session""" + + data: Optional[EndPomodoroData] = None + + +class GetPomodoroStatusResponse(TimedOperationResponse): + """Response for getting current Pomodoro session status""" + + data: Optional[PomodoroSessionData] = None + + +# Pomodoro Session Detail Models (with activities and focus metrics) + + +class PomodoroActivityData(BaseModel): + """Activity data for Pomodoro session detail view""" + + id: str + title: str + description: str + start_time: str + end_time: str + session_duration_minutes: int + work_phase: Optional[int] = None # Which work round (1-4) + focus_score: Optional[float] = None # Focus metric (0.0-1.0) + topic_tags: List[str] = [] + source_event_ids: List[str] = [] # Deprecated, for backward compatibility + source_action_ids: List[str] = [] # NEW: Primary source for action-based aggregation + aggregation_mode: str = "action_based" # NEW: 'event_based' or 'action_based' + + +class PhaseTimelineItem(BaseModel): + """Single phase in timeline (work or break)""" + + phase_type: Literal["work", "break"] + phase_number: int # 1-based round number + start_time: str + end_time: str + duration_minutes: int + + +class FocusMetrics(BaseModel): + """Focus metrics for a Pomodoro session""" + + overall_focus_score: float # Weighted average focus score (0.0-1.0) + activity_count: int # Number of activities in session + topic_diversity: int # Number of unique topics + average_activity_duration: float # Average duration per activity (minutes) + focus_level: str # Human-readable level: excellent/good/moderate/low + + +class LLMFocusAnalysis(BaseModel): + """Detailed focus analysis from LLM evaluation""" + + strengths: List[str] # Focus strengths (2-4 items) + weaknesses: List[str] # Focus weaknesses (1-3 items) + suggestions: List[str] # Improvement suggestions (2-4 items) + + +class LLMFocusDimensionScores(BaseModel): + """Detailed dimension scores from LLM evaluation""" + + topic_consistency: int # 0-100 score for topic consistency + duration_depth: int # 0-100 score for duration depth + switching_rhythm: int # 0-100 score for switching rhythm + work_quality: int # 0-100 score for work quality + goal_orientation: int # 0-100 score for goal orientation + + +class LLMFocusEvaluation(BaseModel): + """Complete LLM-based focus evaluation result""" + + focus_score: int # 0-100 integer score + focus_level: Literal["excellent", "good", "moderate", "low"] # Focus quality level + dimension_scores: LLMFocusDimensionScores # Detailed dimension scores + analysis: LLMFocusAnalysis # Detailed analysis + work_type: Literal[ + "development", + "writing", + "learning", + "research", + "design", + "communication", + "entertainment", + "productivity_analysis", + "mixed", + "unclear", + ] # Type of work activity + is_focused_work: bool # Whether it's high-quality focused work + distraction_percentage: int # Distraction time percentage (0-100) + deep_work_minutes: float # Deep work duration (minutes) + context_summary: str # Overall work summary + + +class PomodoroSessionDetailData(BaseModel): + """Detailed Pomodoro session with activities and focus metrics""" + + session: Dict[str, Any] # Full session data + activities: List[PomodoroActivityData] + focus_metrics: FocusMetrics # Calculated focus metrics + llm_focus_evaluation: Optional[LLMFocusEvaluation] = None # LLM-based detailed evaluation + phase_timeline: List[PhaseTimelineItem] = [] # Work/break phase timeline + + +class GetPomodoroSessionDetailRequest(BaseModel): + """Request to get detailed Pomodoro session information""" + + session_id: str + + +class GetPomodoroSessionDetailResponse(TimedOperationResponse): + """Response with detailed Pomodoro session data""" + + data: Optional[PomodoroSessionDetailData] = None + + +class DeletePomodoroSessionRequest(BaseModel): + """Request to delete a Pomodoro session""" + + session_id: str + + +class DeletePomodoroSessionData(BaseModel): + """Data returned after deleting a session""" + + session_id: str + deleted_activities_count: int + + +class DeletePomodoroSessionResponse(TimedOperationResponse): + """Response after deleting a Pomodoro session""" + + data: Optional[DeletePomodoroSessionData] = None + + +# Knowledge responses +class KnowledgeData(BaseModel): + """Knowledge item data""" + + id: str + title: str + description: str + keywords: List[str] + created_at: Optional[str] = None + source_action_id: Optional[str] = None + favorite: bool = False + deleted: bool = False + + +class ToggleKnowledgeFavoriteResponse(TimedOperationResponse): + """Response after toggling knowledge favorite status""" + + data: Optional[KnowledgeData] = None + + +class CreateKnowledgeResponse(TimedOperationResponse): + """Response after creating knowledge""" + + data: Optional[KnowledgeData] = None + + +class UpdateKnowledgeResponse(TimedOperationResponse): + """Response after updating knowledge""" + + data: Optional[KnowledgeData] = None + + +class MergeSuggestion(BaseModel): + """Represents a suggested merge of similar knowledge entries""" + + group_id: str + knowledge_ids: List[str] + merged_title: str + merged_description: str + merged_keywords: List[str] + similarity_score: float + merge_reason: str + estimated_tokens: int + + +class AnalyzeKnowledgeMergeResponse(TimedOperationResponse): + """Response after analyzing knowledge for merge suggestions""" + + suggestions: List[MergeSuggestion] + total_estimated_tokens: int + analyzed_count: int + suggested_merge_count: int + + +class MergeResult(BaseModel): + """Result of executing a merge operation""" + + group_id: str + merged_knowledge_id: str + deleted_knowledge_ids: List[str] + success: bool + error: Optional[str] = None + + +class ExecuteKnowledgeMergeResponse(TimedOperationResponse): + """Response after executing knowledge merge operations""" + + results: List[MergeResult] + total_merged: int + total_deleted: int + + +# ==================== Pomodoro Work Phases ==================== + + +class WorkPhaseInfo(BaseModel): + """Work phase status information""" + + phase_id: str + phase_number: int + status: str # pending/processing/completed/failed + processing_error: Optional[str] = None + retry_count: int + phase_start_time: str + phase_end_time: Optional[str] = None + activity_count: int + + +class GetSessionPhasesResponse(TimedOperationResponse): + """Response for get_session_phases endpoint""" + + data: Optional[List[WorkPhaseInfo]] = None + + +# ============ Todo Responses ============ + + +class TodoData(BaseModel): + """Todo data for API responses""" + + id: str + title: str + description: str + keywords: List[str] + created_at: Optional[str] = None + completed: bool = False + deleted: bool = False + scheduled_date: Optional[str] = None + scheduled_time: Optional[str] = None + scheduled_end_time: Optional[str] = None + recurrence_rule: Optional[Dict[str, Any]] = None + expires_at: Optional[str] = None + source_type: str = "ai" + + +class CreateTodoResponse(TimedOperationResponse): + """Response for creating a todo manually""" + + data: Optional[TodoData] = None + + +class CleanupExpiredTodosResponse(TimedOperationResponse): + """Response for cleanup_expired_todos endpoint""" + + data: Optional[Dict[str, int]] = None + + +class CleanupSoftDeletedResponse(TimedOperationResponse): + """Response for cleanup_soft_deleted endpoint""" + + data: Optional[Dict[str, int]] = None diff --git a/backend/perception/active_monitor_tracker.py b/backend/perception/active_monitor_tracker.py index c1dcbcd..69c671b 100644 --- a/backend/perception/active_monitor_tracker.py +++ b/backend/perception/active_monitor_tracker.py @@ -1,8 +1,13 @@ """ Active monitor tracker for smart screenshot filtering -Tracks which monitor is currently active based on mouse position, +Tracks which monitor is currently active based on mouse/keyboard activity, enabling smart screenshot capture that only captures the active screen. + +Key behavior: +- Always uses the last known mouse position to determine active monitor +- Never falls back to capturing all monitors due to inactivity +- This ensures correct behavior when watching videos (cursor hidden but still on one screen) """ import time @@ -14,19 +19,13 @@ class ActiveMonitorTracker: - """Tracks the currently active monitor based on mouse activity""" - - def __init__(self, inactive_timeout: float = 30.0): - """ - Initialize active monitor tracker + """Tracks the currently active monitor based on user activity""" - Args: - inactive_timeout: Seconds of inactivity before considering all monitors active - """ + def __init__(self): + """Initialize active monitor tracker""" self._current_monitor_index: int = 1 # Default to primary monitor self._monitors_info: List[Dict] = [] self._last_activity_time: float = time.time() - self._inactive_timeout: float = inactive_timeout self._last_mouse_position: Optional[tuple[int, int]] = None def update_monitors_info(self, monitors: List[Dict]) -> None: @@ -64,6 +63,16 @@ def update_from_mouse(self, x: int, y: int) -> None: self._last_activity_time = time.time() self._last_mouse_position = (x, y) + def update_from_keyboard(self) -> None: + """ + Update last activity time from keyboard event + + Keeps the tracker aware of user activity even when mouse isn't moving + (e.g., watching videos, reading content, typing) + """ + self._last_activity_time = time.time() + logger.debug("Activity time updated from keyboard event") + def _get_monitor_from_position(self, x: int, y: int) -> int: """ Determine which monitor contains the given coordinates @@ -103,21 +112,15 @@ def get_active_monitor_index(self) -> int: """ Get the currently active monitor index + Always returns the last known active monitor based on mouse position. + Never returns "capture all" - maintains single monitor focus even + during long periods of inactivity (e.g., watching videos). + Returns: Monitor index (1-based) """ return self._current_monitor_index - def should_capture_all_monitors(self) -> bool: - """ - Check if we should capture all monitors (due to inactivity timeout) - - Returns: - True if inactive for too long, False otherwise - """ - inactive_duration = time.time() - self._last_activity_time - return inactive_duration >= self._inactive_timeout - def get_stats(self) -> Dict: """Get tracker statistics for debugging""" inactive_duration = time.time() - self._last_activity_time @@ -126,6 +129,4 @@ def get_stats(self) -> Dict: "monitors_count": len(self._monitors_info), "last_mouse_position": self._last_mouse_position, "inactive_duration_seconds": round(inactive_duration, 2), - "should_capture_all": self.should_capture_all_monitors(), - "inactive_timeout": self._inactive_timeout, } diff --git a/backend/perception/image_consumer.py b/backend/perception/image_consumer.py new file mode 100644 index 0000000..729d838 --- /dev/null +++ b/backend/perception/image_consumer.py @@ -0,0 +1,478 @@ +""" +Image Consumer - Batch screenshot buffering for Pomodoro mode + +Accumulates screenshot metadata and generates RawRecords in batches +when threshold is reached (count-based OR time-based). + +This component provides: +1. Dual buffer architecture (accumulating + processing) +2. State machine for batch lifecycle management +3. Timeout protection for long-running LLM calls +4. Hybrid threshold triggering (count + time) +""" + +import uuid +from collections import OrderedDict +from dataclasses import dataclass +from datetime import datetime +from enum import Enum +from typing import Any, Callable, Dict, List, Optional + +from core.logger import get_logger +from core.models import RawRecord, RecordType + +logger = get_logger(__name__) + + +class BatchState(Enum): + """Batch processing state""" + IDLE = "idle" # No processing, ready to accept new batch + READY_TO_PROCESS = "ready_to_process" # Batch prepared, about to trigger + PROCESSING = "processing" # Batch being processed by LLM + + +@dataclass +class ScreenshotMetadata: + """Lightweight screenshot metadata for buffering""" + img_hash: str + timestamp: datetime + monitor_index: int + monitor_info: Dict[str, Any] + active_window: Optional[Dict[str, Any]] + screenshot_path: str + width: int + height: int + + +class ImageConsumer: + """ + Screenshot buffering and batch RawRecord generation for Pomodoro mode + + Uses dual-buffer architecture to isolate accumulating screenshots + from those being processed by LLM, preventing data confusion during + HTTP timeouts. + + Batch Triggering: + - COUNT threshold: 50 screenshots (default) + - TIME threshold: 60 seconds elapsed (default) + - OVERFLOW protection: 200 screenshots max (safety limit) + + State Machine: + IDLE → READY_TO_PROCESS → PROCESSING → IDLE + """ + + def __init__( + self, + count_threshold: int = 50, + time_threshold: float = 60.0, + max_buffer_size: int = 200, + processing_timeout: float = 720.0, + on_batch_ready: Optional[Callable[[List[RawRecord], Callable[[bool], None]], None]] = None, + image_manager: Optional[Any] = None, + ): + """ + Initialize ImageConsumer + + Args: + count_threshold: Trigger batch when this many screenshots accumulated + time_threshold: Trigger batch after this many seconds elapsed + max_buffer_size: Emergency flush when buffer exceeds this size + processing_timeout: Timeout for batch processing (default: 12 minutes) + on_batch_ready: Callback to invoke with generated RawRecords batch + Signature: (records: List[RawRecord], on_completed: Callable[[bool], None]) -> None + image_manager: Reference to ImageManager for cache validation + """ + self.count_threshold = count_threshold + self.time_threshold = time_threshold + self.max_buffer_size = max_buffer_size + self.processing_timeout = processing_timeout + self.on_batch_ready = on_batch_ready + self.image_manager = image_manager + + # Dual buffer architecture + self._accumulating_buffer: List[ScreenshotMetadata] = [] + self._processing_buffer: Optional[List[ScreenshotMetadata]] = None + + # State machine + self._batch_state: BatchState = BatchState.IDLE + self._processing_batch_id: Optional[str] = None + self._processing_start_time: Optional[datetime] = None + + # Track first screenshot time for time threshold + self._first_screenshot_time: Optional[datetime] = None + + # Statistics + self.stats: Dict[str, int] = { + "total_screenshots_consumed": 0, + "batches_generated": 0, + "total_records_generated": 0, + "cache_misses": 0, + "timeout_resets": 0, + "overflow_flushes": 0, + "concurrent_trigger_attempts": 0, + "count_triggers": 0, + "time_triggers": 0, + } + + logger.info( + f"ImageConsumer initialized: count_threshold={count_threshold}, " + f"time_threshold={time_threshold}s, max_buffer={max_buffer_size}, " + f"processing_timeout={processing_timeout}s" + ) + + def consume_screenshot( + self, + img_hash: str, + timestamp: datetime, + monitor_index: int, + monitor_info: Dict[str, Any], + active_window: Optional[Dict[str, Any]], + screenshot_path: str, + width: int = 0, + height: int = 0, + ) -> None: + """ + Consume a screenshot (store metadata for later batch processing) + + Lightweight storage - only metadata (~1KB), actual image in ImageManager cache. + + Args: + img_hash: Perceptual hash of the screenshot + timestamp: Capture timestamp + monitor_index: Monitor index + monitor_info: Monitor information dict + active_window: Active window information (optional) + screenshot_path: Virtual path to screenshot + width: Screenshot width + height: Screenshot height + """ + metadata = ScreenshotMetadata( + img_hash=img_hash, + timestamp=timestamp, + monitor_index=monitor_index, + monitor_info=monitor_info, + active_window=active_window, + screenshot_path=screenshot_path, + width=width, + height=height, + ) + + # Always add to accumulating buffer (even when processing) + self._accumulating_buffer.append(metadata) + self.stats["total_screenshots_consumed"] += 1 + + # Track first screenshot time for time threshold + if self._first_screenshot_time is None: + self._first_screenshot_time = timestamp + + # Log state for debugging + if self._batch_state == BatchState.PROCESSING: + logger.debug( + f"Screenshot queued (batch {self._processing_batch_id} processing): " + f"accumulating={len(self._accumulating_buffer)}" + ) + + # Check overflow protection + if len(self._accumulating_buffer) >= self.max_buffer_size: + logger.warning( + f"Buffer overflow detected ({len(self._accumulating_buffer)} >= {self.max_buffer_size}), " + f"force flushing" + ) + self.stats["overflow_flushes"] += 1 + self._trigger_batch_generation("overflow") + return + + # Check if should trigger batch + should_trigger, reason = self._should_trigger_batch() + if should_trigger and reason: # Ensure reason is not None + self._trigger_batch_generation(reason) + + def _should_trigger_batch(self) -> tuple[bool, Optional[str]]: + """ + Check if batch should be triggered + + Returns: + (should_trigger, reason) + reason: "count" | "time" | None + """ + # Don't trigger if already processing + if self._batch_state == BatchState.PROCESSING: + return False, None + + # Check count threshold + if len(self._accumulating_buffer) >= self.count_threshold: + return True, "count" + + # Check time threshold + if self._first_screenshot_time: + elapsed = (datetime.now() - self._first_screenshot_time).total_seconds() + if elapsed >= self.time_threshold: + return True, "time" + + return False, None + + def _trigger_batch_generation(self, reason: str) -> None: + """ + Trigger batch generation (state transition) + + Args: + reason: Trigger reason ("count", "time", "overflow", "manual") + """ + if self._batch_state == BatchState.PROCESSING: + logger.warning( + f"Cannot trigger new batch (reason: {reason}): " + f"previous batch {self._processing_batch_id} still processing" + ) + self.stats["concurrent_trigger_attempts"] += 1 + return + + if len(self._accumulating_buffer) == 0: + logger.debug("No screenshots to process, skipping batch generation") + return + + # Update trigger stats + if reason == "count": + self.stats["count_triggers"] += 1 + elif reason == "time": + self.stats["time_triggers"] += 1 + + # State: IDLE → READY_TO_PROCESS + self._batch_state = BatchState.READY_TO_PROCESS + + # Move accumulating buffer to processing buffer + self._processing_buffer = self._accumulating_buffer + self._accumulating_buffer = [] + self._first_screenshot_time = None # Reset time tracker + + # Generate batch ID and record start time + self._processing_batch_id = str(uuid.uuid4()) + self._processing_start_time = datetime.now() + + batch_size = len(self._processing_buffer) + logger.info( + f"Triggering batch generation: batch_id={self._processing_batch_id[:8]}, " + f"size={batch_size}, reason={reason}" + ) + + # Generate RawRecords from metadata + try: + records = self._generate_raw_records_batch(self._processing_buffer) + + # State: READY_TO_PROCESS → PROCESSING + self._batch_state = BatchState.PROCESSING + + logger.debug( + f"Batch {self._processing_batch_id[:8]} ready: " + f"{len(records)}/{batch_size} records generated" + ) + + # Invoke callback with completion handler + if self.on_batch_ready: + self.on_batch_ready(records, self._on_batch_completed) + else: + logger.warning("No on_batch_ready callback registered, auto-completing") + self._on_batch_completed(True) + + except Exception as e: + logger.error(f"Failed to generate batch {self._processing_batch_id[:8]}: {e}", exc_info=True) + # Reset state on failure + self._processing_buffer = None + self._batch_state = BatchState.IDLE + self._processing_batch_id = None + self._processing_start_time = None + + def _generate_raw_records_batch(self, metadata_list: List[ScreenshotMetadata]) -> List[RawRecord]: + """ + Generate RawRecords from buffered screenshot metadata + + Args: + metadata_list: List of screenshot metadata + + Returns: + List of RawRecord objects ready for processing + """ + records = [] + failed_count = 0 + + for meta in metadata_list: + # Verify image still in cache (if image_manager available) + if self.image_manager: + if not self.image_manager.get_from_cache(meta.img_hash): + logger.warning( + f"Image {meta.img_hash[:8]} evicted from cache before batch processing, " + f"skipping this screenshot" + ) + failed_count += 1 + self.stats["cache_misses"] += 1 + continue + + # Create RawRecord from metadata + screenshot_data = { + "action": "capture", + "width": meta.width, + "height": meta.height, + "format": "JPEG", + "hash": meta.img_hash, + "monitor": meta.monitor_info, + "monitor_index": meta.monitor_index, + "timestamp": meta.timestamp.isoformat(), + "screenshotPath": meta.screenshot_path, + } + + if meta.active_window: + screenshot_data["active_window"] = meta.active_window + + record = RawRecord( + timestamp=meta.timestamp, + type=RecordType.SCREENSHOT_RECORD, + data=screenshot_data, + screenshot_path=meta.screenshot_path, + ) + + records.append(record) + + if failed_count > 0: + logger.warning( + f"Lost {failed_count}/{len(metadata_list)} screenshots due to cache eviction. " + f"Consider increasing image.memory_cache_size in config." + ) + + self.stats["batches_generated"] += 1 + self.stats["total_records_generated"] += len(records) + + return records + + def _on_batch_completed(self, success: bool) -> None: + """ + Batch processing completion callback + + Args: + success: Whether batch processing succeeded + """ + if not self._processing_start_time: + logger.warning("Batch completion called but no processing start time recorded") + return + + elapsed = (datetime.now() - self._processing_start_time).total_seconds() + batch_id_short = self._processing_batch_id[:8] if self._processing_batch_id else "unknown" + + if success: + logger.info(f"Batch {batch_id_short} completed successfully in {elapsed:.1f}s") + else: + logger.error(f"Batch {batch_id_short} failed after {elapsed:.1f}s") + + # Clear processing buffer + self._processing_buffer = None + self._processing_batch_id = None + self._processing_start_time = None + + # State: PROCESSING → IDLE + self._batch_state = BatchState.IDLE + + logger.debug( + f"State reset to IDLE, accumulating buffer size: {len(self._accumulating_buffer)}" + ) + + def check_processing_timeout(self) -> bool: + """ + Check if currently processing batch has timed out + + Should be called periodically (e.g., on each screenshot event). + + Returns: + True if timeout detected and state was reset + """ + if self._batch_state != BatchState.PROCESSING: + return False + + if not self._processing_start_time: + return False + + elapsed = (datetime.now() - self._processing_start_time).total_seconds() + + if elapsed > self.processing_timeout: + batch_id_short = self._processing_batch_id[:8] if self._processing_batch_id else "unknown" + logger.error( + f"Batch {batch_id_short} processing timeout detected: " + f"{elapsed:.1f}s > {self.processing_timeout}s, forcing reset" + ) + + # Force reset state (discard timed-out batch) + self._processing_buffer = None + self._processing_batch_id = None + self._processing_start_time = None + self._batch_state = BatchState.IDLE + + self.stats["timeout_resets"] += 1 + + logger.warning( + f"State reset due to timeout, accumulating buffer size: {len(self._accumulating_buffer)}" + ) + + return True + + return False + + def flush(self) -> List[RawRecord]: + """ + Force flush all buffered screenshots (called on Pomodoro end) + + Flushes both accumulating and processing buffers. + + Returns: + List of RawRecord objects from both buffers + """ + records = [] + + # Flush processing buffer if exists + if self._processing_buffer: + logger.info( + f"Flushing processing buffer: batch_id={self._processing_batch_id[:8] if self._processing_batch_id else 'none'}, " + f"size={len(self._processing_buffer)}" + ) + processing_records = self._generate_raw_records_batch(self._processing_buffer) + records.extend(processing_records) + + # Clear processing state + self._processing_buffer = None + self._processing_batch_id = None + self._processing_start_time = None + self._batch_state = BatchState.IDLE + + # Flush accumulating buffer + if self._accumulating_buffer: + logger.info( + f"Flushing accumulating buffer: size={len(self._accumulating_buffer)}" + ) + accumulating_records = self._generate_raw_records_batch(self._accumulating_buffer) + records.extend(accumulating_records) + + # Clear accumulating buffer + self._accumulating_buffer = [] + self._first_screenshot_time = None + + if records: + logger.info(f"Flushed total {len(records)} records from both buffers") + else: + logger.debug("No buffered screenshots to flush") + + return records + + def get_stats(self) -> Dict[str, Any]: + """ + Get consumer statistics + + Returns: + Dictionary with statistics including buffer sizes + """ + return { + **self.stats, + "accumulating_buffer_size": len(self._accumulating_buffer), + "processing_buffer_size": len(self._processing_buffer) if self._processing_buffer else 0, + "batch_state": self._batch_state.value, + "processing_batch_id": self._processing_batch_id, + "processing_elapsed_seconds": ( + (datetime.now() - self._processing_start_time).total_seconds() + if self._processing_start_time else None + ), + } diff --git a/backend/perception/image_manager.py b/backend/perception/image_manager.py index b4e9495..33ae9f0 100644 --- a/backend/perception/image_manager.py +++ b/backend/perception/image_manager.py @@ -30,7 +30,7 @@ def __init__( str ] = None, # Screenshot storage root directory (override config) enable_memory_first: bool = True, # Enable memory-first storage strategy - memory_ttl: int = 75, # TTL for memory-only images (seconds) + memory_ttl: int = 180, # TTL for memory-only images (seconds) - Updated to 180s to meet recommended minimum ): # Try to read custom path from configuration try: @@ -69,6 +69,15 @@ def __init__( # Image metadata: hash -> (timestamp, is_persisted) self._image_metadata: dict[str, Tuple[datetime, bool]] = {} + # Persistence statistics tracking + self.persistence_stats = { + "total_persist_attempts": 0, + "successful_persists": 0, + "failed_persists": 0, + "cache_misses": 0, + "already_persisted": 0, + } + self._ensure_directories() logger.debug( @@ -79,6 +88,14 @@ def __init__( f"quality={thumbnail_quality}, base_dir={self.base_dir}" ) + # Validation: Warn if TTL seems too low for reliable persistence + if self.memory_ttl < 120: + logger.warning( + f"Memory TTL ({self.memory_ttl}s) is low and may cause image persistence failures. " + f"Recommended: ≥180s for reliable persistence. " + f"Increase 'image.memory_ttl_multiplier' in config.toml to fix." + ) + def _select_thumbnail_size(self, img: Image.Image) -> Tuple[int, int]: """Choose target size based on orientation and resolution""" width, height = img.size @@ -255,7 +272,7 @@ def _create_thumbnail(self, img_bytes: bytes) -> bytes: return img_bytes # Return original if thumbnail creation fails def process_image_for_cache(self, img_hash: str, img_bytes: bytes) -> None: - """Process image: create thumbnail and store based on memory-first strategy + """Process image: create thumbnail and store both in memory and disk for reliability Args: img_hash: Image hash value @@ -264,17 +281,20 @@ def process_image_for_cache(self, img_hash: str, img_bytes: bytes) -> None: try: # Create thumbnail thumbnail_bytes = self._create_thumbnail(img_bytes) + thumbnail_base64 = base64.b64encode(thumbnail_bytes).decode("utf-8") - if self.enable_memory_first: - # Memory-first: store in memory only - thumbnail_base64 = base64.b64encode(thumbnail_bytes).decode("utf-8") - self.add_to_cache(img_hash, thumbnail_base64) - self._image_metadata[img_hash] = (datetime.now(), False) # Mark as memory-only - logger.debug(f"Stored image in memory: {img_hash[:8]}...") - else: - # Legacy: immediate disk save - self.save_thumbnail(img_hash, thumbnail_bytes) - logger.debug(f"Processed image (thumbnail only) for hash: {img_hash[:8]}...") + # Always store in memory for fast access + self.add_to_cache(img_hash, thumbnail_base64) + + # Always persist to disk immediately to prevent image loss + # This ensures images are never lost even if: + # 1. Memory cache is full and LRU evicts them + # 2. TTL cleanup removes them + # 3. System crashes before action persistence + self.save_thumbnail(img_hash, thumbnail_bytes) + self._image_metadata[img_hash] = (datetime.now(), True) # Mark as persisted + + logger.debug(f"Stored image in memory AND disk: {img_hash[:8]}...") except Exception as e: logger.error(f"Failed to process image for cache: {e}") @@ -288,15 +308,19 @@ def persist_image(self, img_hash: str) -> bool: True if persisted successfully, False otherwise """ try: + self.persistence_stats["total_persist_attempts"] += 1 + # Check if already persisted metadata = self._image_metadata.get(img_hash) if metadata and metadata[1]: # is_persisted = True + self.persistence_stats["already_persisted"] += 1 logger.debug(f"Image already persisted: {img_hash[:8]}...") return True # Check if exists on disk already thumbnail_path = self.thumbnails_dir / f"{img_hash}.jpg" if thumbnail_path.exists(): + self.persistence_stats["already_persisted"] += 1 # Update metadata self._image_metadata[img_hash] = (datetime.now(), True) logger.debug(f"Image already on disk: {img_hash[:8]}...") @@ -305,6 +329,8 @@ def persist_image(self, img_hash: str) -> bool: # Get from memory cache img_data = self.get_from_cache(img_hash) if not img_data: + self.persistence_stats["failed_persists"] += 1 + self.persistence_stats["cache_misses"] += 1 logger.warning( f"Image not found in memory cache (likely evicted): {img_hash[:8]}... " f"Cannot persist to disk." @@ -317,11 +343,13 @@ def persist_image(self, img_hash: str) -> bool: # Update metadata self._image_metadata[img_hash] = (datetime.now(), True) + self.persistence_stats["successful_persists"] += 1 logger.debug(f"Persisted image to disk: {img_hash[:8]}...") return True except Exception as e: + self.persistence_stats["failed_persists"] += 1 logger.error(f"Failed to persist image {img_hash[:8]}: {e}") return False @@ -557,6 +585,14 @@ def get_stats(self) -> Dict[str, Any]: else: memory_only_count += 1 + # Calculate persistence success rate + total_attempts = self.persistence_stats["total_persist_attempts"] + success_rate = ( + self.persistence_stats["successful_persists"] / total_attempts + if total_attempts > 0 + else 1.0 + ) + return { "memory_cache_count": memory_count, "memory_cache_limit": self.memory_cache_size, @@ -572,6 +608,9 @@ def get_stats(self) -> Dict[str, Any]: "memory_ttl_seconds": self.memory_ttl, "memory_only_images": memory_only_count, "persisted_images_in_cache": persisted_count, + # Persistence stats + "persistence_success_rate": round(success_rate, 4), + "persistence_stats": self.persistence_stats, } except Exception as e: @@ -659,7 +698,7 @@ def get_image_manager() -> ImageManager: """Get image manager singleton""" global _image_manager if _image_manager is None: - _image_manager = ImageManager() + _image_manager = init_image_manager() return _image_manager @@ -674,16 +713,26 @@ def init_image_manager(**kwargs) -> ImageManager: config = get_config().load() - enable_memory_first = config.get("image.enable_memory_first", True) - processing_interval = config.get("monitoring.processing_interval", 30) - multiplier = config.get("image.memory_ttl_multiplier", 2.5) - ttl_min = config.get("image.memory_ttl_min", 60) - ttl_max = config.get("image.memory_ttl_max", 120) + # Access nested config values correctly + image_config = config.get("image", {}) + monitoring_config = config.get("monitoring", {}) + + enable_memory_first = image_config.get("enable_memory_first", True) + processing_interval = monitoring_config.get("processing_interval", 30) + multiplier = image_config.get("memory_ttl_multiplier", 2.5) + ttl_min = image_config.get("memory_ttl_min", 60) + ttl_max = image_config.get("memory_ttl_max", 300) # Calculate dynamic TTL calculated_ttl = int(processing_interval * multiplier) memory_ttl = max(ttl_min, min(ttl_max, calculated_ttl)) + logger.debug( + f"ImageManager config: processing_interval={processing_interval}, " + f"multiplier={multiplier}, ttl_min={ttl_min}, ttl_max={ttl_max}, " + f"calculated_ttl={calculated_ttl}, final_memory_ttl={memory_ttl}" + ) + if "enable_memory_first" not in kwargs: kwargs["enable_memory_first"] = enable_memory_first if "memory_ttl" not in kwargs: @@ -691,10 +740,10 @@ def init_image_manager(**kwargs) -> ImageManager: logger.info( f"ImageManager: memory_first={enable_memory_first}, " - f"TTL={memory_ttl}s (processing_interval={processing_interval}s)" + f"TTL={memory_ttl}s (processing_interval={processing_interval}s * multiplier={multiplier})" ) except Exception as e: - logger.warning(f"Failed to calculate memory TTL from config: {e}") + logger.warning(f"Failed to calculate memory TTL from config: {e}", exc_info=True) _image_manager = ImageManager(**kwargs) return _image_manager diff --git a/backend/perception/manager.py b/backend/perception/manager.py index d79a2bf..6f31348 100644 --- a/backend/perception/manager.py +++ b/backend/perception/manager.py @@ -6,6 +6,7 @@ """ import asyncio +import time from datetime import datetime from typing import Any, Callable, Dict, Optional @@ -53,8 +54,7 @@ def __init__( self.on_system_wake_callback = on_system_wake # Initialize active monitor tracker for smart screenshot capture - # inactive_timeout will be loaded from settings during start() - self.monitor_tracker = ActiveMonitorTracker(inactive_timeout=30.0) + self.monitor_tracker = ActiveMonitorTracker() # Create active window capture first (needed by screenshot capture for context enrichment) # No callback needed as window info is embedded in screenshot records @@ -89,6 +89,15 @@ def __init__( self.keyboard_enabled = True self.mouse_enabled = True + # Pomodoro mode state + self.pomodoro_session_id: Optional[str] = None + + # ImageConsumer for Pomodoro buffering (initialized when Pomodoro starts) + self.image_consumer: Optional[Any] = None + + # Event loop reference (set when start() is called) + self._event_loop: Optional[asyncio.AbstractEventLoop] = None + def _on_screen_lock(self) -> None: """Screen lock/system sleep callback""" if not self.is_running: @@ -115,6 +124,16 @@ def _on_screen_lock(self) -> None: except Exception as e: logger.error(f"Failed to pause capturers: {e}") + def _notify_record_available(self) -> None: + """Notify coordinator that a new record is available (event-driven triggering)""" + try: + from core.coordinator import get_coordinator + coordinator = get_coordinator() + if coordinator: + coordinator.notify_records_available(count=1) + except Exception as e: + logger.error(f"Failed to notify coordinator: {e}") + def _on_screen_unlock(self) -> None: """Screen unlock/system wake callback""" if not self.is_running or not self.is_paused: @@ -148,13 +167,25 @@ def _on_keyboard_event(self, record: RawRecord) -> None: return try: - # Record all keyboard events for subsequent processing to preserve usage context + # Tag with Pomodoro session ID if active (for future use) + if self.pomodoro_session_id: + record.data['pomodoro_session_id'] = self.pomodoro_session_id + + # Always add to memory for real-time viewing and processing self.storage.add_record(record) self.event_buffer.add(record) if self.on_data_captured: self.on_data_captured(record) + # Notify coordinator that a new record is available + self._notify_record_available() + + # Update monitor tracker with keyboard activity + # This keeps smart capture aware of user activity even when mouse is hidden + if self.monitor_tracker: + self.monitor_tracker.update_from_keyboard() + logger.debug( f"Keyboard event recorded: {record.data.get('key', 'unknown')}" ) @@ -170,12 +201,20 @@ def _on_mouse_event(self, record: RawRecord) -> None: try: # Only record important mouse events if self.mouse_capture.is_important_event(record.data): + # Tag with Pomodoro session ID if active (for future use) + if self.pomodoro_session_id: + record.data['pomodoro_session_id'] = self.pomodoro_session_id + + # Always add to memory for real-time viewing and processing self.storage.add_record(record) self.event_buffer.add(record) if self.on_data_captured: self.on_data_captured(record) + # Notify coordinator that a new record is available + self._notify_record_available() + logger.debug( f"Mouse event recorded: {record.data.get('action', 'unknown')}" ) @@ -201,18 +240,59 @@ def _on_screenshot_event(self, record: RawRecord) -> None: try: if record: # Screenshot may be None (duplicate screenshots) - self.storage.add_record(record) - self.event_buffer.add(record) - - if self.on_data_captured: - self.on_data_captured(record) + # NEW: In Pomodoro mode with buffering, check timeout and route to ImageConsumer + if self.pomodoro_session_id and self.image_consumer: + # Periodic timeout check (performance: ~1ms per check) + self.image_consumer.check_processing_timeout() + + # Send to ImageConsumer for buffering + self.image_consumer.consume_screenshot( + img_hash=record.data.get("hash", ""), + timestamp=record.timestamp, + monitor_index=record.data.get("monitor_index", 0), + monitor_info=record.data.get("monitor", {}), + active_window=record.data.get("active_window"), + screenshot_path=record.screenshot_path or "", + width=record.data.get("width", 0), + height=record.data.get("height", 0), + ) + # Don't process immediately - ImageConsumer will batch + logger.debug(f"Screenshot buffered for Pomodoro session: {record.data.get('hash', '')[:8]}") + return + + # Normal flow (non-Pomodoro or buffering disabled) + self._on_screenshot_captured(record) - logger.debug( - f"Screenshot recorded: {record.data.get('width', 0)}x{record.data.get('height', 0)}" - ) except Exception as e: logger.error(f"Failed to process screenshot event: {e}") + def _on_screenshot_captured(self, record: RawRecord) -> None: + """Process captured screenshot (common path for buffered and normal flow) + + Args: + record: RawRecord containing screenshot data + """ + try: + # Tag with Pomodoro session ID if active + if self.pomodoro_session_id: + record.data['pomodoro_session_id'] = self.pomodoro_session_id + + # Always add to memory for real-time viewing and processing + self.storage.add_record(record) + self.event_buffer.add(record) + + if self.on_data_captured: + self.on_data_captured(record) + + # Notify coordinator that a new record is available + self._notify_record_available() + + logger.debug( + f"Screenshot recorded: {record.data.get('width', 0)}x{record.data.get('height', 0)}" + ) + except Exception as e: + logger.error(f"Failed to record screenshot: {e}") + async def start(self) -> None: """Start perception manager""" from datetime import datetime @@ -226,6 +306,9 @@ async def start(self) -> None: self.is_running = True self.is_paused = False + # Store event loop reference for sync callbacks + self._event_loop = asyncio.get_running_loop() + # Load perception settings from core.settings import get_settings @@ -233,9 +316,8 @@ async def start(self) -> None: self.keyboard_enabled = settings.get("perception.keyboard_enabled", True) self.mouse_enabled = settings.get("perception.mouse_enabled", True) - # Load smart capture settings - inactive_timeout = settings.get("screenshot.inactive_timeout", 30.0) - self.monitor_tracker._inactive_timeout = float(inactive_timeout) + # Note: inactive_timeout setting is no longer used + # Smart capture now always uses last known mouse position # Start screen state monitor start_time = datetime.now() @@ -309,6 +391,9 @@ async def stop(self) -> None: self.is_running = False self.is_paused = False + # Clear event loop reference + self._event_loop = None + # Stop screen state monitor self.screen_state_monitor.stop() @@ -345,19 +430,29 @@ async def stop(self) -> None: async def _screenshot_loop(self) -> None: """Screenshot loop task""" try: - loop = asyncio.get_event_loop() + iteration = 0 + while self.is_running: - # Execute synchronous screenshot operation in thread pool to avoid blocking event loop - await loop.run_in_executor( - None, - self.screenshot_capture.capture_with_interval, - self.capture_interval, - ) - await asyncio.sleep(0.1) # Brief sleep to avoid excessive CPU usage + iteration += 1 + loop_start = time.time() + + # Directly call capture() without interval checking + # The loop itself controls the timing + try: + self.screenshot_capture.capture() + except Exception as e: + logger.error(f"Screenshot capture failed: {e}", exc_info=True) + + elapsed = time.time() - loop_start + + # Sleep for the interval, accounting for capture time + sleep_time = max(0.1, self.capture_interval - elapsed) + await asyncio.sleep(sleep_time) + except asyncio.CancelledError: logger.debug("Screenshot loop task cancelled") except Exception as e: - logger.error(f"Screenshot loop task failed: {e}") + logger.error(f"Screenshot loop task failed: {e}", exc_info=True) async def _cleanup_loop(self) -> None: """Cleanup loop task""" @@ -408,6 +503,18 @@ def get_records_in_timeframe( """Get records within specified time range""" return self.storage.get_records_in_timeframe(start_time, end_time) + def get_expiring_records(self, expiration_threshold: Optional[int] = None) -> list: + """ + Get records that are about to expire (for pre-processing before cleanup) + + Args: + expiration_threshold: Time in seconds before expiration to consider + + Returns: + List of records that are about to expire + """ + return self.storage.get_expiring_records(expiration_threshold) + def get_records_in_last_n_seconds(self, seconds: int) -> list: """Get records from last N seconds""" from datetime import datetime, timedelta @@ -451,6 +558,19 @@ def _update_monitor_info(self) -> None: except Exception as e: logger.error(f"Failed to update monitor info: {e}") + def handle_monitors_changed(self) -> None: + """Handle monitor configuration changes (rotation, resolution, etc.) + + This should be called when the 'monitors-changed' event is detected + to update monitor bounds in the active monitor tracker. + """ + if not self.is_running: + logger.debug("Perception not running, skipping monitor update") + return + + logger.info("Monitor configuration changed, updating monitor tracker") + self._update_monitor_info() + def get_stats(self) -> Dict[str, Any]: """Get manager statistics""" try: @@ -523,3 +643,123 @@ def update_perception_settings( logger.debug( f"Perception settings updated: keyboard={self.keyboard_enabled}, mouse={self.mouse_enabled}" ) + + def set_pomodoro_session(self, session_id: str) -> None: + """ + Set Pomodoro session ID for tagging captured records + + Args: + session_id: Pomodoro session identifier + """ + self.pomodoro_session_id = session_id + + # Initialize ImageConsumer for this session + from core.settings import get_settings + + config = get_settings().get_pomodoro_buffering_config() + + if config["enabled"]: + from perception.image_consumer import ImageConsumer + from perception.image_manager import get_image_manager + + self.image_consumer = ImageConsumer( + count_threshold=config["count_threshold"], + time_threshold=config["time_threshold"], + max_buffer_size=config["max_buffer_size"], + processing_timeout=config["processing_timeout"], + on_batch_ready=self._on_batch_ready, + image_manager=get_image_manager(), + ) + logger.info( + f"✓ ImageConsumer initialized for Pomodoro session {session_id}: " + f"count_threshold={config['count_threshold']}, " + f"time_threshold={config['time_threshold']}s" + ) + else: + logger.debug("Screenshot buffering disabled, using normal flow") + + logger.debug(f"✓ Pomodoro session set: {session_id}") + + def clear_pomodoro_session(self) -> None: + """Clear Pomodoro session ID (exit Pomodoro mode)""" + session_id = self.pomodoro_session_id + + # Flush remaining screenshots before clearing + if self.image_consumer: + remaining = self.image_consumer.flush() + if remaining: + logger.info(f"Flushing {len(remaining)} buffered screenshots") + for record in remaining: + # Tag with session ID and process normally + record.data['pomodoro_session_id'] = session_id + self._on_screenshot_captured(record) + + # Get stats before cleanup + stats = self.image_consumer.get_stats() + logger.info( + f"ImageConsumer stats: batches={stats['batches_generated']}, " + f"records={stats['total_records_generated']}, " + f"cache_misses={stats['cache_misses']}, " + f"timeouts={stats['timeout_resets']}" + ) + + self.image_consumer = None + + self.pomodoro_session_id = None + logger.debug(f"✓ Pomodoro session cleared: {session_id}") + + def _on_batch_ready( + self, + raw_records: list, + on_completed: Callable[[bool], None], + ) -> None: + """ + Batch ready callback from ImageConsumer + + Args: + raw_records: List of RawRecord objects from batch + on_completed: Callback to invoke when batch processing completes + Signature: (success: bool) -> None + """ + logger.debug(f"Processing batch of {len(raw_records)} RawRecords") + + try: + for record in raw_records: + # Tag with session ID (should already be set but ensure consistency) + if self.pomodoro_session_id: + record.data['pomodoro_session_id'] = self.pomodoro_session_id + + # Process through normal screenshot captured path + self._on_screenshot_captured(record) + + # Batch processed successfully + on_completed(True) + logger.debug(f"✓ Batch of {len(raw_records)} records processed successfully") + + # Note: _on_screenshot_captured already calls _notify_record_available() for each record + # No need to notify again here + + except Exception as e: + logger.error(f"Failed to process batch: {e}", exc_info=True) + on_completed(False) + + async def _persist_raw_record(self, record: RawRecord) -> None: + """ + Persist raw record to database (Pomodoro mode) + + Args: + record: RawRecord to persist + """ + try: + import json + from core.db import get_db + + db = get_db() + await db.raw_records.save( + timestamp=record.timestamp.isoformat(), + record_type=record.type.value, # Convert enum to string + data=json.dumps(record.data), + pomodoro_session_id=record.data.get('pomodoro_session_id'), + ) + except Exception as e: + logger.error(f"Failed to persist raw record: {e}", exc_info=True) diff --git a/backend/perception/screenshot_capture.py b/backend/perception/screenshot_capture.py index 9a90aa9..7e2d20d 100644 --- a/backend/perception/screenshot_capture.py +++ b/backend/perception/screenshot_capture.py @@ -40,9 +40,6 @@ def __init__( self._last_screenshot_time = 0 # Per-monitor deduplication state self._last_hashes: Dict[int, Optional[str]] = {} - self._last_force_save_times: Dict[int, float] = {} - # Force save interval: read from settings, default 60 seconds - self._force_save_interval = self._get_force_save_interval() self._screenshot_count = 0 self._compression_quality = 90 self._max_width = 2560 @@ -91,7 +88,7 @@ def _get_enabled_monitor_indices(self) -> List[int]: """Load enabled monitor indices from settings, with smart capture support. Returns: - - If smart capture enabled and tracker available: [active_monitor_index] + - If smart capture enabled and tracker available: intersection of active monitor and enabled monitors - If screen settings exist and some are enabled: list of enabled indices - If screen settings exist but none enabled: empty list (respect user's choice) - If no screen settings configured or read fails: [1] (primary) @@ -99,35 +96,41 @@ def _get_enabled_monitor_indices(self) -> List[int]: try: settings = get_settings() + # Get configured enabled monitors from settings + # Use the proper method to read screen settings from database + screens = settings.get_screenshot_screen_settings() + if not screens: + # Not configured -> default to primary only + configured_enabled = [1] + else: + configured_enabled = [int(s.get("monitor_index")) for s in screens if s.get("is_enabled")] + # Deduplicate while preserving order + seen = set() + result: List[int] = [] + for i in configured_enabled: + if i not in seen: + seen.add(i) + result.append(i) + configured_enabled = result + # Check if smart capture is enabled smart_capture_enabled = settings.get("screenshot.smart_capture_enabled", False) if smart_capture_enabled and self.monitor_tracker: - # Check if we should capture all monitors due to inactivity - if self.monitor_tracker.should_capture_all_monitors(): - logger.debug( - "Inactivity timeout reached, capturing all enabled monitors" - ) - else: - # Only capture the active monitor - active_index = self.monitor_tracker.get_active_monitor_index() + # Smart capture: only capture the active monitor if it's enabled in settings + # Always uses last known mouse position, never falls back to "capture all" + active_index = self.monitor_tracker.get_active_monitor_index() + if active_index in configured_enabled: logger.debug(f"Smart capture: only capturing monitor {active_index}") return [active_index] + else: + logger.debug( + f"Smart capture: active monitor {active_index} is not enabled in settings, skipping" + ) + return [] - # Fallback to configured screen settings - screens = settings.get("screenshot.screen_settings", None) - if not isinstance(screens, list) or len(screens) == 0: - # Not configured -> default to primary only - return [1] - enabled = [int(s.get("monitor_index")) for s in screens if s.get("is_enabled")] - # Deduplicate while preserving order - seen = set() - result: List[int] = [] - for i in enabled: - if i not in seen: - seen.add(i) - result.append(i) - return result + # Return configured enabled monitors (smart capture disabled) + return configured_enabled except Exception as e: logger.warning(f"Failed to read screen settings, fallback to primary: {e}") return [1] @@ -135,7 +138,7 @@ def _get_enabled_monitor_indices(self) -> List[int]: def _capture_one_monitor( self, sct: MSSBase, monitor_index: int ) -> Optional[RawRecord]: - """Capture one monitor and emit a record if not duplicate (or force-save interval reached).""" + """Capture one monitor and emit a record if not duplicate.""" try: monitor = sct.monitors[monitor_index] screenshot = sct.grab(monitor) @@ -145,24 +148,13 @@ def _capture_one_monitor( img = self._process_image(img) img_hash = self._calculate_hash(img) - current_time = time.time() last_hash = self._last_hashes.get(monitor_index) - last_force = self._last_force_save_times.get(monitor_index, 0.0) is_duplicate = last_hash == img_hash - time_since_force_save = current_time - last_force - should_force_save = time_since_force_save >= self._force_save_interval - if is_duplicate and not should_force_save: + if is_duplicate: logger.debug(f"Skip duplicate screenshot on monitor {monitor_index}") return None - if is_duplicate and should_force_save: - logger.debug( - f"Force keep duplicate screenshot on monitor {monitor_index} " - f"({time_since_force_save:.1f}s since last save)" - ) - self._last_force_save_times[monitor_index] = current_time - self._last_hashes[monitor_index] = img_hash self._screenshot_count += 1 @@ -323,7 +315,9 @@ def capture_with_interval(self, interval: float = 1.0): return current_time = time.time() - if current_time - self._last_screenshot_time >= interval: + time_since_last = current_time - self._last_screenshot_time + + if time_since_last >= interval: self.capture() self._last_screenshot_time = current_time @@ -366,23 +360,6 @@ def get_stats(self) -> dict: "tmp_dir": self.tmp_dir, } - def _get_force_save_interval(self) -> float: - """Get force save interval from settings - - Returns the interval in seconds after which a screenshot will be force-saved - even if it appears to be a duplicate. Defaults to 60 seconds (1 minute). - """ - try: - settings = get_settings() - interval = settings.get_screenshot_force_save_interval() - logger.debug(f"Force save interval: {interval}s") - return interval - except Exception as e: - logger.warning( - f"Failed to read force save interval from settings: {e}, using default 60s" - ) - return 60.0 # Default 1 minute - def _ensure_tmp_dir(self) -> None: """Ensure tmp directory exists""" try: diff --git a/backend/perception/storage.py b/backend/perception/storage.py index f34aa1a..9a70c8c 100644 --- a/backend/perception/storage.py +++ b/backend/perception/storage.py @@ -113,13 +113,52 @@ def _cleanup_expired_records(self) -> None: current_time = datetime.now() cutoff_time = current_time - timedelta(seconds=self.window_size) - # Remove expired records from left side + # Count how many records will be removed + removed_count = 0 while self.records and self.records[0].timestamp < cutoff_time: self.records.popleft() + removed_count += 1 + + if removed_count > 0: + logger.debug(f"Cleaned up {removed_count} expired records (older than {self.window_size}s)") except Exception as e: logger.error(f"Failed to clean up expired records: {e}") + def get_expiring_records(self, expiration_threshold: Optional[int] = None) -> List[RawRecord]: + """ + Get records that are about to expire (for pre-processing before cleanup) + + Args: + expiration_threshold: Time in seconds before expiration to consider (default: 90% of window_size) + + Returns: + List of records that are about to expire + """ + try: + with self.lock: + if expiration_threshold is None: + # Default: records older than 90% of window size (e.g., 54s for 60s window) + expiration_threshold = int(self.window_size * 0.9) + + current_time = datetime.now() + expiration_cutoff = current_time - timedelta(seconds=expiration_threshold) + + # Find records that are old but not yet expired + expiring_records = [] + for record in self.records: + if record.timestamp < expiration_cutoff: + expiring_records.append(record) + else: + # Records are sorted by time, so we can stop here + break + + return expiring_records + + except Exception as e: + logger.error(f"Failed to get expiring records: {e}") + return [] + def clear(self) -> None: """Clear all records""" try: diff --git a/backend/processing/behavior_analyzer.py b/backend/processing/behavior_analyzer.py new file mode 100644 index 0000000..f2d51bd --- /dev/null +++ b/backend/processing/behavior_analyzer.py @@ -0,0 +1,458 @@ +""" +Behavior Analyzer - Classify user behavior patterns from keyboard/mouse data + +This module analyzes keyboard and mouse activity patterns to distinguish between: +- Operation (active work): coding, writing, designing +- Browsing (passive consumption): reading, watching, learning +- Mixed: combination of both + +The classification is based on: +- Keyboard activity (60% weight): event frequency, typing intensity, modifier usage +- Mouse activity (40% weight): click/scroll/drag ratios, position variance +""" + +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from core.logger import get_logger +from core.models import RawRecord, RecordType + +logger = get_logger(__name__) + + +class BehaviorAnalyzer: + """ + Analyzes keyboard and mouse patterns to classify user behavior + + Behavior Types: + - operation: Active work (coding, writing, designing) + - browsing: Passive consumption (reading, watching, browsing) + - mixed: Combination of both + """ + + def __init__( + self, + operation_threshold: float = 0.6, + browsing_threshold: float = 0.3, + keyboard_weight: float = 0.6, + mouse_weight: float = 0.4, + ): + """ + Initialize behavior analyzer + + Args: + operation_threshold: Score threshold for operation classification (default: 0.6) + browsing_threshold: Score threshold for browsing classification (default: 0.3) + keyboard_weight: Weight for keyboard metrics 0-1 (default: 0.6) + mouse_weight: Weight for mouse metrics 0-1 (default: 0.4) + """ + self.operation_threshold = operation_threshold + self.browsing_threshold = browsing_threshold + self.keyboard_weight = keyboard_weight + self.mouse_weight = mouse_weight + + logger.debug( + f"BehaviorAnalyzer initialized " + f"(op_threshold={operation_threshold}, " + f"browse_threshold={browsing_threshold}, " + f"kb_weight={keyboard_weight}, " + f"mouse_weight={mouse_weight})" + ) + + def analyze( + self, + keyboard_records: Optional[List[RawRecord]] = None, + mouse_records: Optional[List[RawRecord]] = None, + ) -> Dict[str, Any]: + """ + Analyze behavior from keyboard and mouse records + + Args: + keyboard_records: Filtered keyboard events + mouse_records: Filtered mouse events + + Returns: + Behavior analysis result dictionary with structure: + { + "behavior_type": "operation" | "browsing" | "mixed", + "confidence": 0.0-1.0, + "metrics": { + "keyboard_activity": {...}, + "mouse_activity": {...}, + "combined_score": float, + "reasoning": str + } + } + """ + # Calculate time window + time_window = self._calculate_time_window(keyboard_records, mouse_records) + + # Analyze keyboard patterns + kb_metrics = self._analyze_keyboard_activity( + keyboard_records or [], time_window + ) + + # Analyze mouse patterns + mouse_metrics = self._analyze_mouse_activity(mouse_records or [], time_window) + + # Classify behavior + result = self._classify_behavior(kb_metrics, mouse_metrics) + + logger.debug( + f"Behavior analysis: {result['behavior_type']} " + f"(confidence={result['confidence']:.2f}, " + f"kb_score={kb_metrics['score']:.2f}, " + f"mouse_score={mouse_metrics['score']:.2f})" + ) + + return result + + def _calculate_time_window( + self, + keyboard_records: Optional[List[RawRecord]], + mouse_records: Optional[List[RawRecord]], + ) -> float: + """ + Calculate analysis time window from record timestamps + + Args: + keyboard_records: Keyboard event records + mouse_records: Mouse event records + + Returns: + Time window duration in seconds (minimum 1.0) + """ + all_records = [] + if keyboard_records: + all_records.extend(keyboard_records) + if mouse_records: + all_records.extend(mouse_records) + + if not all_records: + return 20.0 # default 20 seconds + + timestamps = [r.timestamp for r in all_records] + time_span = (max(timestamps) - min(timestamps)).total_seconds() + + return max(time_span, 1.0) # at least 1 second + + def _analyze_keyboard_activity( + self, keyboard_records: List[RawRecord], time_window: float + ) -> Dict[str, Any]: + """ + Analyze keyboard patterns to determine activity level + + Metrics: + 1. Events per minute (EPM) - raw activity level + 2. Typing intensity - char keys / total keys ratio + 3. Modifier usage - shortcuts (cmd+key, ctrl+key) ratio + + Args: + keyboard_records: Keyboard event records + time_window: Analysis window duration in seconds + + Returns: + Keyboard activity metrics dict + """ + if not keyboard_records: + return { + "events_per_minute": 0, + "typing_intensity": 0, + "modifier_usage": 0, + "score": 0, + } + + # Calculate events per minute + epm = len(keyboard_records) / (time_window / 60) if time_window > 0 else 0 + + # Classify key types + char_keys = 0 # a-z, 0-9 (actual typing) + special_keys = 0 # enter, backspace, arrows (navigation) + modifier_combos = 0 # cmd+s, ctrl+c (shortcuts) + + for record in keyboard_records: + key_type = record.data.get("key_type", "") + modifiers = record.data.get("modifiers", []) + + if key_type == "char": + char_keys += 1 + elif key_type == "special": + special_keys += 1 + + if modifiers and len(modifiers) > 0: + modifier_combos += 1 + + total_keys = char_keys + special_keys + + # Calculate ratios + typing_intensity = char_keys / total_keys if total_keys > 0 else 0 + modifier_usage = modifier_combos / total_keys if total_keys > 0 else 0 + + # Scoring (0-1 scale) + # Operation: high EPM (>10), high typing (>0.6), moderate modifiers (>0.1) + # Browsing: low EPM (<5), low typing (<0.3), few modifiers (<0.05) + + epm_score = min(epm / 20, 1.0) # normalize to 20 EPM = 1.0 + typing_score = typing_intensity + modifier_score = min(modifier_usage / 0.2, 1.0) # 20% modifiers = 1.0 + + # Weighted combination + score = epm_score * 0.4 + typing_score * 0.4 + modifier_score * 0.2 + + return { + "events_per_minute": epm, + "typing_intensity": typing_intensity, + "modifier_usage": modifier_usage, + "score": score, + } + + def _analyze_mouse_activity( + self, mouse_records: List[RawRecord], time_window: float + ) -> Dict[str, Any]: + """ + Analyze mouse patterns to determine work style + + Patterns: + - Operation: precise clicks, drags, frequent position changes + - Browsing: continuous scrolling, few clicks, linear movement + + Args: + mouse_records: Mouse event records + time_window: Analysis window duration in seconds + + Returns: + Mouse activity metrics dict + """ + if not mouse_records: + return { + "click_ratio": 0, + "scroll_ratio": 0, + "drag_ratio": 0, + "precision_score": 0, + "score": 0, + } + + # Count event types + clicks = 0 + scrolls = 0 + drags = 0 + positions = [] + + for record in mouse_records: + action = record.data.get("action", "") + + if action in ["click", "press", "release"]: + clicks += 1 + position = record.data.get("position") + if position: + positions.append(position) + elif action == "scroll": + scrolls += 1 + elif action in ["drag", "drag_end"]: + drags += 1 + position = record.data.get("position") + if position: + positions.append(position) + + total_events = clicks + scrolls + drags + + # Calculate ratios + click_ratio = clicks / total_events if total_events > 0 else 0 + scroll_ratio = scrolls / total_events if total_events > 0 else 0 + drag_ratio = drags / total_events if total_events > 0 else 0 + + # Calculate precision score (movement variance) + # High variance = precise targeting (operation) + # Low variance = linear scrolling (browsing) + precision_score = self._calculate_position_variance(positions) + + # Scoring + # Operation: high clicks (>0.4), low scroll (<0.5), high precision (>0.5) + # Browsing: low clicks (<0.2), high scroll (>0.7), low precision (<0.3) + + click_score = click_ratio + scroll_score = 1.0 - scroll_ratio # inverse (low scroll = high score) + drag_score = drag_ratio * 2.0 # drags strongly indicate operation + precision_score_normalized = precision_score + + score = ( + click_score * 0.3 + + scroll_score * 0.2 + + min(drag_score, 1.0) * 0.2 + + precision_score_normalized * 0.3 + ) + + return { + "click_ratio": click_ratio, + "scroll_ratio": scroll_ratio, + "drag_ratio": drag_ratio, + "precision_score": precision_score, + "score": score, + } + + def _calculate_position_variance(self, positions: List[tuple]) -> float: + """ + Calculate normalized variance of mouse positions + + Higher variance indicates precise targeting (operation mode) + Lower variance indicates linear movement (browsing mode) + + Args: + positions: List of (x, y) position tuples + + Returns: + Normalized variance score (0-1) + """ + if len(positions) < 2: + return 0.5 # neutral score for insufficient data + + # Calculate variance manually (avoiding numpy dependency) + x_coords = [p[0] for p in positions] + y_coords = [p[1] for p in positions] + + # Mean + x_mean = sum(x_coords) / len(x_coords) + y_mean = sum(y_coords) / len(y_coords) + + # Variance + x_var = sum((x - x_mean) ** 2 for x in x_coords) / len(x_coords) + y_var = sum((y - y_mean) ** 2 for y in y_coords) / len(y_coords) + + # Average variance + avg_variance = (x_var + y_var) / 2 + + # Normalize to 0-1 (assuming screen ~1920x1080) + # High variance (100000+) = 1.0, low variance = 0.0 + normalized = min(avg_variance / 100000, 1.0) + + return normalized + + def _classify_behavior( + self, kb_metrics: Dict[str, Any], mouse_metrics: Dict[str, Any] + ) -> Dict[str, Any]: + """ + Combine keyboard and mouse metrics to determine behavior type + + Weighting: + - Keyboard: 60% (stronger signal for operation vs browsing) + - Mouse: 40% (supporting evidence) + + Args: + kb_metrics: Keyboard activity metrics + mouse_metrics: Mouse activity metrics + + Returns: + Classification result with behavior_type, confidence, and metrics + """ + kb_score = kb_metrics["score"] + mouse_score = mouse_metrics["score"] + + # Weighted combination + combined_score = kb_score * self.keyboard_weight + mouse_score * self.mouse_weight + + # Classification thresholds + if combined_score >= self.operation_threshold: + behavior_type = "operation" + confidence = min(combined_score, 1.0) + elif combined_score <= self.browsing_threshold: + behavior_type = "browsing" + confidence = min(1.0 - combined_score, 1.0) + else: + behavior_type = "mixed" + # Lower confidence in middle range + confidence = 1.0 - abs(combined_score - 0.5) * 2 + + # Generate reasoning + reasoning = self._generate_reasoning( + behavior_type, kb_metrics, mouse_metrics, combined_score + ) + + return { + "behavior_type": behavior_type, + "confidence": confidence, + "metrics": { + "keyboard_activity": kb_metrics, + "mouse_activity": mouse_metrics, + "combined_score": combined_score, + "reasoning": reasoning, + }, + } + + def _generate_reasoning( + self, + behavior_type: str, + kb_metrics: Dict[str, Any], + mouse_metrics: Dict[str, Any], + combined_score: float, + ) -> str: + """ + Generate human-readable explanation for classification + + Args: + behavior_type: Classified behavior type + kb_metrics: Keyboard activity metrics + mouse_metrics: Mouse activity metrics + combined_score: Combined classification score + + Returns: + Reasoning string explaining the classification + """ + kb_epm = kb_metrics["events_per_minute"] + typing = kb_metrics["typing_intensity"] + scroll_ratio = mouse_metrics["scroll_ratio"] + click_ratio = mouse_metrics["click_ratio"] + + if behavior_type == "operation": + return ( + f"High keyboard activity ({kb_epm:.1f} EPM) with " + f"{typing*100:.0f}% typing and {click_ratio*100:.0f}% mouse clicks " + f"indicates active work (coding, writing, or design)" + ) + elif behavior_type == "browsing": + return ( + f"Low keyboard activity ({kb_epm:.1f} EPM) with " + f"{scroll_ratio*100:.0f}% scrolling indicates passive consumption " + f"(reading, watching, or browsing)" + ) + else: + return ( + f"Mixed activity pattern (score: {combined_score:.2f}) suggests " + f"combination of active work and information gathering" + ) + + def format_behavior_context( + self, analysis_result: Dict[str, Any], language: str = "en" + ) -> str: + """ + Format behavior analysis for prompt inclusion + + Args: + analysis_result: Result from analyze() method + language: Language code ("en" or "zh") + + Returns: + Formatted context string for LLM prompt + """ + behavior_type = analysis_result["behavior_type"] + confidence = analysis_result["confidence"] + reasoning = analysis_result["metrics"]["reasoning"] + + kb_epm = analysis_result["metrics"]["keyboard_activity"]["events_per_minute"] + kb_typing = analysis_result["metrics"]["keyboard_activity"]["typing_intensity"] + mouse_clicks = analysis_result["metrics"]["mouse_activity"]["click_ratio"] + mouse_scrolls = analysis_result["metrics"]["mouse_activity"]["scroll_ratio"] + + if language == "zh": + # Chinese format + context = f"""行为类型:{behavior_type.upper()} (置信度: {confidence:.0%}) +- 键盘:{kb_epm:.1f} 次/分钟 ({kb_typing:.0%} 打字) +- 鼠标:{mouse_clicks:.0%} 点击, {mouse_scrolls:.0%} 滚动 +- 分析:{reasoning}""" + else: + # English format + context = f"""Behavior Type: {behavior_type.upper()} (Confidence: {confidence:.0%}) +- Keyboard: {kb_epm:.1f} events/min ({kb_typing:.0%} typing) +- Mouse: {mouse_clicks:.0%} clicks, {mouse_scrolls:.0%} scrolling +- Analysis: {reasoning}""" + + return context.strip() diff --git a/backend/processing/coding_detector.py b/backend/processing/coding_detector.py new file mode 100644 index 0000000..b8b6463 --- /dev/null +++ b/backend/processing/coding_detector.py @@ -0,0 +1,231 @@ +""" +Coding Scene Detector - Detect coding applications for adaptive filtering + +This module identifies coding environments (IDEs, terminals, editors) from +active window information to apply coding-specific optimization thresholds. + +When a coding scene is detected, the image filter uses more permissive +thresholds since code editors have minimal visual changes during typing. +""" + +import re +from typing import Optional + +from core.logger import get_logger + +logger = get_logger(__name__) + +# Bundle IDs for common coding applications (macOS) +CODING_BUNDLE_IDS = [ + # IDEs and Editors + "com.microsoft.VSCode", + "com.microsoft.VSCodeInsiders", + "com.apple.dt.Xcode", + "com.jetbrains.", # All JetBrains IDEs (prefix match) + "com.sublimetext.", + "com.sublimehq.Sublime-Text", + "org.vim.", + "com.qvacua.VimR", + "com.neovide.", + "com.github.atom", + "dev.zed.Zed", + "com.cursor.Cursor", + "ai.cursor.", + # Terminals + "com.googlecode.iterm2", + "com.apple.Terminal", + "io.alacritty", + "com.github.wez.wezterm", + "net.kovidgoyal.kitty", + "co.zeit.hyper", + # Other development tools + "com.postmanlabs.mac", + "com.insomnia.app", +] + +# App names for common coding applications +CODING_APP_NAMES = [ + # IDEs + "Visual Studio Code", + "Code", + "Code - Insiders", + "Cursor", + "Zed", + "Xcode", + "IntelliJ IDEA", + "PyCharm", + "WebStorm", + "CLion", + "GoLand", + "Rider", + "Android Studio", + "PhpStorm", + "RubyMine", + "DataGrip", + # Editors + "Sublime Text", + "Atom", + "Vim", + "NeoVim", + "Neovide", + "VimR", + "Emacs", + "Nova", + # Terminals + "Terminal", + "iTerm", + "iTerm2", + "Alacritty", + "WezTerm", + "kitty", + "Hyper", + # Other + "Postman", + "Insomnia", +] + +# Window title patterns that indicate coding activity +CODE_FILE_PATTERNS = [ + # Source code file extensions + r"\.(py|pyw|pyx|pxd)\b", # Python + r"\.(js|mjs|cjs|jsx)\b", # JavaScript + r"\.(ts|tsx|mts|cts)\b", # TypeScript + r"\.(go|mod|sum)\b", # Go + r"\.(rs|rlib)\b", # Rust + r"\.(java|kt|kts|scala)\b", # JVM languages + r"\.(c|h|cpp|hpp|cc|cxx)\b", # C/C++ + r"\.(swift|m|mm)\b", # Apple languages + r"\.(rb|rake|gemspec)\b", # Ruby + r"\.(php|phtml)\b", # PHP + r"\.(vue|svelte)\b", # Frontend frameworks + r"\.(html|htm|css|scss|sass|less)\b", # Web + r"\.(json|yaml|yml|toml|xml)\b", # Config files + r"\.(sh|bash|zsh|fish)\b", # Shell scripts + r"\.(sql|graphql|gql)\b", # Query languages + r"\.(md|mdx|rst|txt)\b", # Documentation + # Git and version control + r"\[Git\]", + r"- Git$", + r"COMMIT_EDITMSG", + r"\.git/", + # Editor indicators + r"- vim$", + r"- nvim$", + r"- NVIM$", + r"\(INSERT\)", + r"\(NORMAL\)", + r"\(VISUAL\)", + # Terminal indicators + r"@.*:.*\$", # Shell prompt pattern + r"bash|zsh|fish|sh\s*$", +] + + +class CodingSceneDetector: + """ + Detects if the current active window is a coding environment. + + Uses multiple signals: + 1. Application bundle ID (most reliable on macOS) + 2. Application name + 3. Window title patterns (code file extensions, git, vim modes) + """ + + def __init__(self): + """Initialize the detector with compiled regex patterns.""" + self._compiled_patterns = [ + re.compile(pattern, re.IGNORECASE) + for pattern in CODE_FILE_PATTERNS + ] + logger.debug( + f"CodingSceneDetector initialized with " + f"{len(CODING_BUNDLE_IDS)} bundle IDs, " + f"{len(CODING_APP_NAMES)} app names, " + f"{len(CODE_FILE_PATTERNS)} title patterns" + ) + + def is_coding_scene( + self, + app_name: Optional[str] = None, + bundle_id: Optional[str] = None, + window_title: Optional[str] = None, + ) -> bool: + """ + Determine if the active window is a coding environment. + + Args: + app_name: Application name (e.g., "Visual Studio Code") + bundle_id: macOS bundle identifier (e.g., "com.microsoft.VSCode") + window_title: Window title (e.g., "main.py - project - VSCode") + + Returns: + True if any signal indicates a coding environment. + """ + # Check bundle ID (most reliable) + if bundle_id: + for pattern in CODING_BUNDLE_IDS: + if bundle_id.startswith(pattern): + logger.debug(f"Coding scene detected via bundle_id: {bundle_id}") + return True + + # Check app name + if app_name: + # Direct match + if app_name in CODING_APP_NAMES: + logger.debug(f"Coding scene detected via app_name: {app_name}") + return True + # Case-insensitive partial match for some apps + app_lower = app_name.lower() + for coding_app in CODING_APP_NAMES: + if coding_app.lower() in app_lower: + logger.debug( + f"Coding scene detected via app_name (partial): {app_name}" + ) + return True + + # Check window title patterns + if window_title: + for pattern in self._compiled_patterns: + if pattern.search(window_title): + logger.debug( + f"Coding scene detected via window_title pattern: " + f"'{window_title}' matched '{pattern.pattern}'" + ) + return True + + return False + + def is_coding_record(self, record_data: Optional[dict]) -> bool: + """ + Check if a record's active_window indicates coding. + + Args: + record_data: Record data dict containing active_window info. + + Returns: + True if the record is from a coding environment. + """ + if not record_data: + return False + + active_window = record_data.get("active_window", {}) + if not active_window: + return False + + return self.is_coding_scene( + app_name=active_window.get("app_name"), + bundle_id=active_window.get("app_bundle_id"), + window_title=active_window.get("window_title"), + ) + + +# Singleton instance for reuse +_detector: Optional[CodingSceneDetector] = None + + +def get_coding_detector() -> CodingSceneDetector: + """Get or create the singleton CodingSceneDetector instance.""" + global _detector + if _detector is None: + _detector = CodingSceneDetector() + return _detector diff --git a/backend/processing/image/analysis.py b/backend/processing/image/analysis.py index aa40410..8f48b62 100644 --- a/backend/processing/image/analysis.py +++ b/backend/processing/image/analysis.py @@ -162,7 +162,8 @@ def has_significant_content( self, img_bytes: bytes, min_contrast: float = 50.0, - min_activity: float = 10.0 + min_activity: float = 10.0, + is_coding_scene: bool = False, ) -> Tuple[bool, str]: """ Determine if image has significant content worth processing @@ -171,12 +172,19 @@ def has_significant_content( img_bytes: Image data min_contrast: Minimum contrast threshold min_activity: Minimum edge activity threshold + is_coding_scene: Whether this is from a coding environment + (relaxed thresholds for dark themes) Returns: (should_include, reason) """ metrics = self.analyze(img_bytes) + # Adjust thresholds for coding scenes (dark themes, minimal visual changes) + if is_coding_scene: + min_contrast = 25.0 # Lower for dark-themed IDEs + min_activity = 5.0 # Lower for typing (small pixel changes) + # Rule 1: High contrast = potentially meaningful interface change if metrics["contrast"] > min_contrast: self.stats["high_contrast_included"] += 1 @@ -187,7 +195,11 @@ def has_significant_content( self.stats["motion_detected"] += 1 return True, "Motion detected" - # Rule 3: Low contrast and no motion = possibly blank/waiting screen + # Rule 3: For coding scenes, check complexity (text patterns) + if is_coding_scene and metrics["complexity"] > 15.0: + return True, "Coding content detected" + + # Rule 4: Low contrast and no motion = possibly blank/waiting screen if metrics["contrast"] < 20: self.stats["static_skipped"] += 1 return False, "Static/blank content" diff --git a/backend/processing/image_filter.py b/backend/processing/image_filter.py index 1af4fd9..cb93ff7 100644 --- a/backend/processing/image_filter.py +++ b/backend/processing/image_filter.py @@ -1,16 +1,22 @@ """ Unified image filtering and optimization Integrates deduplication, content analysis, and compression into a single preprocessing stage + +Supports coding scene detection for adaptive thresholds: +- Coding scenes (IDEs, terminals) use more permissive thresholds +- This helps capture small but meaningful changes during coding """ import base64 import io from collections import deque +from datetime import datetime from typing import Any, Dict, List, Optional, Tuple from core.logger import get_logger from core.models import RawRecord, RecordType from perception.image_manager import get_image_manager +from processing.coding_detector import get_coding_detector logger = get_logger(__name__) @@ -50,6 +56,8 @@ def __init__( enable_content_analysis: bool = True, # Compression settings enable_compression: bool = True, + # Periodic sampling settings + min_sample_interval: float = 30.0, ): """ Initialize unified image filter @@ -62,6 +70,7 @@ def __init__( enable_adaptive_threshold: Enable scene-adaptive thresholds enable_content_analysis: Enable content analysis (skip static screens) enable_compression: Enable image compression + min_sample_interval: Minimum seconds between kept samples in static scenes (default 30) """ self.enable_deduplication = enable_deduplication and IMAGEHASH_AVAILABLE self.similarity_threshold = similarity_threshold @@ -69,6 +78,8 @@ def __init__( self.enable_adaptive_threshold = enable_adaptive_threshold self.enable_content_analysis = enable_content_analysis self.enable_compression = enable_compression + self.min_sample_interval = min_sample_interval + self.last_kept_timestamp: Optional[datetime] = None # Initialize hash algorithms with weights if hash_algorithms is None: @@ -79,6 +90,7 @@ def __init__( self.hash_cache: deque = deque(maxlen=hash_cache_size) self.image_manager = get_image_manager() + self.coding_detector = get_coding_detector() # Initialize components self._init_content_analyzer() @@ -207,8 +219,11 @@ def filter_screenshots(self, records: List[RawRecord]) -> List[RawRecord]: # Step 2: Content analysis if self.enable_content_analysis and self.content_analyzer: + # Check if this is a coding scene for relaxed thresholds + is_coding = self.coding_detector.is_coding_record(record.data) has_content, reason = self.content_analyzer.has_significant_content( - img_bytes + img_bytes, + is_coding_scene=is_coding, ) if not has_content: self.stats["content_filtered"] += 1 @@ -295,6 +310,18 @@ def _check_duplicate( return False, 0.0 try: + # Periodic sampling: force keep at least one sample every min_sample_interval + # This ensures time coverage even in static scenes (reading, watching) + force_keep = False + if self.last_kept_timestamp is not None: + elapsed = (record.timestamp - self.last_kept_timestamp).total_seconds() + if elapsed >= self.min_sample_interval: + force_keep = True + logger.debug( + f"Periodic sampling: keeping screenshot after {elapsed:.1f}s " + f"(interval: {self.min_sample_interval}s)" + ) + # Load PIL Image img = Image.open(io.BytesIO(img_bytes)) @@ -314,15 +341,17 @@ def _check_duplicate( max_similarity = max(max_similarity, similarity) # Detect scene type and get adaptive threshold - scene_type = self._detect_scene_type(max_similarity) + # Pass record to check for coding scene + scene_type = self._detect_scene_type(max_similarity, record) adaptive_threshold = self._get_adaptive_threshold(scene_type) - # Check if duplicate - if max_similarity >= adaptive_threshold: + # Check if duplicate (but respect periodic sampling) + if max_similarity >= adaptive_threshold and not force_keep: return True, max_similarity - # Not duplicate, add to cache + # Not duplicate (or force kept), add to cache and update timestamp self.hash_cache.append((record.timestamp, multi_hash)) + self.last_kept_timestamp = record.timestamp return False, max_similarity except Exception as e: @@ -383,8 +412,24 @@ def _calculate_similarity( return total_similarity / total_weight if total_weight > 0 else 0.0 - def _detect_scene_type(self, similarity: float) -> str: - """Detect scene type based on similarity""" + def _detect_scene_type( + self, similarity: float, record: Optional[RawRecord] = None + ) -> str: + """ + Detect scene type based on similarity and active window context. + + Scene types: + - 'coding': IDEs, terminals, code editors (more permissive threshold) + - 'static': Almost identical content (aggressive deduplication) + - 'video': High similarity with motion (preserve key frames) + - 'normal': Regular interactive content (default threshold) + """ + # Check for coding scene first (highest priority) + if record and record.data: + if self.coding_detector.is_coding_record(record.data): + return 'coding' + + # Similarity-based detection if similarity >= 0.99: return 'static' # Almost identical (documents, reading) elif similarity >= 0.95: @@ -393,11 +438,22 @@ def _detect_scene_type(self, similarity: float) -> str: return 'normal' # Regular interactive content def _get_adaptive_threshold(self, scene_type: str) -> float: - """Get adaptive threshold based on scene type""" + """ + Get adaptive threshold based on scene type. + + Thresholds: + - coding: 0.92 (more permissive, capture small code changes) + - static: 0.85 (aggressive deduplication for static content) + - video: 0.98 (preserve key frames) + - normal: configured threshold (default 0.88) + """ if not self.enable_adaptive_threshold: return self.similarity_threshold - if scene_type == 'static': + if scene_type == 'coding': + # More permissive for coding - capture cursor movement, typing + return 0.92 + elif scene_type == 'static': return 0.85 # Aggressive deduplication for static content elif scene_type == 'video': return 0.98 # Preserve key frames in video @@ -409,8 +465,9 @@ def get_stats(self) -> Dict[str, Any]: return self.stats.copy() def reset_state(self): - """Reset deduplication state (clears hash cache)""" + """Reset deduplication state (clears hash cache and periodic sampling)""" self.hash_cache.clear() + self.last_kept_timestamp = None self.stats = { "total_processed": 0, "duplicates_skipped": 0, diff --git a/backend/processing/pipeline.py b/backend/processing/pipeline.py index 63e93d6..2f0ab20 100644 --- a/backend/processing/pipeline.py +++ b/backend/processing/pipeline.py @@ -2,8 +2,9 @@ Processing pipeline (agent-based architecture) Simplified pipeline that delegates to specialized agents: - raw_records → ActionAgent → actions (complete flow: extract + save) -- actions → EventAgent → events (complete flow: aggregate + save) -- events → SessionAgent → activities (complete flow: aggregate + save) +- actions → SessionAgent → activities (action-based aggregation) + +EventAgent has been DISABLED - using direct action-based aggregation only. Pipeline now only handles: - Filtering raw records @@ -20,6 +21,7 @@ from core.models import RawRecord, RecordType from perception.image_manager import get_image_manager +from .behavior_analyzer import BehaviorAnalyzer from .image_filter import ImageFilter from .image_sampler import ImageSampler from .record_filter import RecordFilter @@ -41,6 +43,8 @@ def __init__( screenshot_hash_cache_size: int = 10, screenshot_hash_algorithms: Optional[List[str]] = None, enable_adaptive_threshold: bool = True, + max_accumulation_time: int = 180, + min_sample_interval: float = 30.0, ): """ Initialize processing pipeline @@ -55,11 +59,15 @@ def __init__( screenshot_hash_cache_size: Number of hashes to cache for comparison screenshot_hash_algorithms: List of hash algorithms to use enable_adaptive_threshold: Whether to enable scene-adaptive thresholds + max_accumulation_time: Maximum time (seconds) before forcing extraction even if threshold not reached + min_sample_interval: Minimum interval (seconds) between kept samples in static scenes """ self.screenshot_threshold = screenshot_threshold self.max_screenshots_per_extraction = max_screenshots_per_extraction self.activity_summary_interval = activity_summary_interval self.language = language + self.max_accumulation_time = max_accumulation_time + self.last_extraction_time: Optional[datetime] = None # Initialize image preprocessing components # ImageFilter: handles deduplication, content analysis, and compression @@ -71,6 +79,7 @@ def __init__( enable_adaptive_threshold=enable_adaptive_threshold, enable_content_analysis=True, # Always enable content analysis enable_compression=True, # Always enable compression + min_sample_interval=min_sample_interval, # Periodic sampling for static scenes ) # ImageSampler: handles sampling when sending to LLM @@ -92,6 +101,15 @@ def __init__( click_merge_threshold=0.5, ) + # BehaviorAnalyzer: analyzes keyboard/mouse patterns to classify user behavior + # Helps LLM distinguish between operation (active work) and browsing (passive consumption) + self.behavior_analyzer = BehaviorAnalyzer( + operation_threshold=0.6, + browsing_threshold=0.3, + keyboard_weight=0.6, + mouse_weight=0.4, + ) + self.db = get_db() self.image_manager = get_image_manager() @@ -108,8 +126,8 @@ def __init__( self.screenshot_accumulator: List[RawRecord] = [] # Note: No scheduled tasks in pipeline anymore - # - Event aggregation: handled by EventAgent - # - Session aggregation: handled by SessionAgent + # - Event aggregation: DISABLED (action-based aggregation only) + # - Session aggregation: handled by SessionAgent (action-based) # - Knowledge merge: handled by KnowledgeAgent # - Todo merge: handled by TodoAgent @@ -141,15 +159,16 @@ async def start(self): return self.is_running = True + self.last_extraction_time = datetime.now() # Initialize time-based trigger - # Note: Event aggregation is now handled by EventAgent (started by coordinator) + # Note: Event aggregation DISABLED - using action-based aggregation only # Note: Todo merge and knowledge merge are handled by TodoAgent and KnowledgeAgent (started by coordinator) # Pipeline now only handles action extraction (triggered by raw record processing) logger.info(f"Processing pipeline started (language: {self.language})") logger.debug(f"- Screenshot threshold: {self.screenshot_threshold}") logger.debug("- Action extraction: handled inline via ActionAgent") - logger.debug("- Event aggregation: handled by EventAgent") + logger.debug("- Event aggregation: DISABLED (action-based aggregation only)") logger.debug("- Todo extraction and merge: handled by TodoAgent") logger.debug("- Knowledge extraction and merge: handled by KnowledgeAgent") @@ -160,7 +179,7 @@ async def stop(self): self.is_running = False - # Note: Event aggregation task removed as aggregation is handled by EventAgent + # Note: Event aggregation DISABLED - using action-based aggregation only # Note: Todo and knowledge merge tasks removed as merging is handled by dedicated agents # Process remaining accumulated screenshots with a hard timeout to avoid shutdown hangs @@ -250,6 +269,22 @@ async def process_raw_records(self, raw_records: List[RawRecord]) -> Dict[str, A ) should_process = True + # Time-based forced processing: ensure activity is captured in static scenes + # (e.g., reading, watching videos) even when screenshot count is low + if ( + not should_process + and len(self.screenshot_accumulator) > 0 + and self.last_extraction_time is not None + ): + time_since_last = (datetime.now() - self.last_extraction_time).total_seconds() + if time_since_last >= self.max_accumulation_time: + logger.info( + f"Time-based forced processing: {time_since_last:.0f}s elapsed " + f"(threshold: {self.max_accumulation_time}s), " + f"processing {len(self.screenshot_accumulator)} accumulated screenshots" + ) + should_process = True + if should_process: # Step 6: Sample screenshots before sending to LLM # This enforces time interval and max count limits @@ -268,9 +303,10 @@ async def process_raw_records(self, raw_records: List[RawRecord]) -> Dict[str, A mouse_records, ) - # Clear accumulator + # Clear accumulator and update extraction time processed_count = len(self.screenshot_accumulator) self.screenshot_accumulator = [] + self.last_extraction_time = datetime.now() return { "processed": processed_count, @@ -325,12 +361,24 @@ async def _extract_actions( logger.error("ActionAgent not available, cannot process actions") raise Exception("ActionAgent not available") + # NEW: Analyze behavior patterns from keyboard/mouse data + behavior_analysis = self.behavior_analyzer.analyze( + keyboard_records=keyboard_records, + mouse_records=mouse_records, + ) + + logger.debug( + f"Behavior analysis: {behavior_analysis['behavior_type']} " + f"(confidence={behavior_analysis['confidence']:.2f})" + ) + # Step 1: Extract scene descriptions from screenshots (RawAgent) logger.debug("Step 1: Extracting scene descriptions via RawAgent") scenes = await self.raw_agent.extract_scenes( records, keyboard_records=keyboard_records, mouse_records=mouse_records, + behavior_analysis=behavior_analysis, # NEW: pass behavior context ) if not scenes: @@ -352,6 +400,7 @@ async def _extract_actions( scenes, keyboard_records=keyboard_records, mouse_records=mouse_records, + behavior_analysis=behavior_analysis, # NEW: pass behavior context ) # Update statistics @@ -470,7 +519,7 @@ def _build_input_usage_hint(self, has_keyboard: bool, has_mouse: bool) -> str: # ============ Scheduled Tasks ============ - # Note: Event aggregation is now handled by EventAgent (started by coordinator) + # Note: Event aggregation DISABLED - using action-based aggregation only # Note: Knowledge merge is now handled by KnowledgeAgent (started by coordinator) # Note: Todo merge is now handled by TodoAgent (started by coordinator) diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index 7424f43..519cf33 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -585,12 +585,17 @@ async def _process_stream( # 3. Stream responses from the LLM (with timeout) full_response = "" + is_error_response = False try: # timeout may not exist when python version < 3.11, but we use python 3.14 async with asyncio.timeout(TIMEOUT_SECONDS): # type: ignore[attr-defined] async for chunk in self.llm_manager.chat_completion_stream(messages, model_id=model_id): full_response += chunk + # Check if chunk contains error pattern (LLM client yields errors as chunks) + if chunk.startswith("[Error]") or chunk.startswith("[错误]"): + is_error_response = True + # Send chunks to the frontend in real time emit_chat_message_chunk( conversation_id=conversation_id, chunk=chunk, done=False @@ -599,7 +604,7 @@ async def _process_stream( error_msg = "Request timeout, please check network connection" logger.error(f"❌ LLM call timed out ({TIMEOUT_SECONDS}s): {conversation_id}") - # Emit the timeout error + # Emit the timeout error with error=True await self.save_message( conversation_id=conversation_id, role="assistant", @@ -607,17 +612,34 @@ async def _process_stream( metadata={"error": True, "error_type": "timeout"}, ) emit_chat_message_chunk( - conversation_id=conversation_id, chunk="", done=True + conversation_id=conversation_id, chunk=error_msg, done=True, error=True + ) + return + + # 4. Handle error responses (LLM client yields errors as chunks instead of raising) + if is_error_response: + logger.error(f"❌ LLM returned error response: {full_response[:100]}") + await self.save_message( + conversation_id=conversation_id, + role="assistant", + content=full_response, + metadata={"error": True, "error_type": "llm"}, + ) + emit_chat_message_chunk( + conversation_id=conversation_id, + chunk=full_response, + done=True, + error=True, ) return - # 4. Save the assistant response + # 5. Save the assistant response (normal completion) assistant_message = await self.save_message( conversation_id=conversation_id, role="assistant", content=full_response ) self._maybe_update_conversation_title(conversation_id) - # 5. Emit the completion signal + # 6. Emit the completion signal emit_chat_message_chunk( conversation_id=conversation_id, chunk="", @@ -642,10 +664,10 @@ async def _process_stream( except Exception as e: logger.error(f"Streaming message failed: {e}", exc_info=True) - # Emit the error signal + # Emit the error signal with error=True error_message = f"[错误] {str(e)[:100]}" emit_chat_message_chunk( - conversation_id=conversation_id, chunk=error_message, done=True + conversation_id=conversation_id, chunk=error_message, done=True, error=True ) # Persist the error message diff --git a/backend/services/knowledge_merger.py b/backend/services/knowledge_merger.py new file mode 100644 index 0000000..390c81a --- /dev/null +++ b/backend/services/knowledge_merger.py @@ -0,0 +1,448 @@ +from typing import List, Dict, Optional, Tuple, Any +from datetime import datetime +import json +import uuid + +from core.logger import get_logger +from llm.manager import get_llm_manager +from llm.prompt_manager import PromptManager +from core.protocols import KnowledgeRepositoryProtocol + +logger = get_logger(__name__) + + +class MergeSuggestion: + """Represents a suggested merge of similar knowledge entries""" + + def __init__( + self, + group_id: str, + knowledge_ids: List[str], + merged_title: str, + merged_description: str, + merged_keywords: List[str], + similarity_score: float, + merge_reason: str, + estimated_tokens: int = 0, + ): + self.group_id = group_id + self.knowledge_ids = knowledge_ids + self.merged_title = merged_title + self.merged_description = merged_description + self.merged_keywords = merged_keywords + self.similarity_score = similarity_score + self.merge_reason = merge_reason + self.estimated_tokens = estimated_tokens + + +class MergeGroup: + """Represents a user-confirmed merge group""" + + def __init__( + self, + group_id: str, + knowledge_ids: List[str], + merged_title: str, + merged_description: str, + merged_keywords: List[str], + merge_reason: Optional[str] = None, + keep_favorite: bool = True, + ): + self.group_id = group_id + self.knowledge_ids = knowledge_ids + self.merged_title = merged_title + self.merged_description = merged_description + self.merged_keywords = merged_keywords + self.merge_reason = merge_reason + self.keep_favorite = keep_favorite + + +class MergeResult: + """Result of executing a merge operation""" + + def __init__( + self, + group_id: str, + merged_knowledge_id: str, + deleted_knowledge_ids: List[str], + success: bool, + error: Optional[str] = None, + ): + self.group_id = group_id + self.merged_knowledge_id = merged_knowledge_id + self.deleted_knowledge_ids = deleted_knowledge_ids + self.success = success + self.error = error + + +class KnowledgeMerger: + """Service for analyzing and merging similar knowledge entries""" + + _instance: Optional["KnowledgeMerger"] = None + _lock: bool = False # Global lock for analysis state + + def __init__( + self, + knowledge_repo: KnowledgeRepositoryProtocol, + prompt_manager: PromptManager, + llm_manager, + ): + self.knowledge_repo = knowledge_repo + self.prompt_manager = prompt_manager + self.llm_manager = llm_manager + + @classmethod + def get_instance( + cls, + knowledge_repo: Optional[KnowledgeRepositoryProtocol] = None, + prompt_manager: Optional[PromptManager] = None, + llm_manager = None, + ) -> "KnowledgeMerger": + """Get singleton instance of KnowledgeMerger""" + if cls._instance is None: + if knowledge_repo is None or prompt_manager is None or llm_manager is None: + raise ValueError( + "First initialization requires all parameters: knowledge_repo, prompt_manager, llm_manager" + ) + cls._instance = cls(knowledge_repo, prompt_manager, llm_manager) + return cls._instance + + @classmethod + def is_locked(cls) -> bool: + """Check if analysis is currently in progress""" + return cls._lock + + @classmethod + def set_lock(cls, locked: bool) -> None: + """Set the lock state""" + cls._lock = locked + + async def health_check(self) -> Tuple[bool, Optional[str]]: + """ + Check if LLM service is available. + + Returns: + (is_available, error_message) + """ + try: + result = await self.llm_manager.health_check() + if result.get("available"): + logger.info( + f"LLM health check passed: {result.get('model')} " + f"({result.get('provider')}), latency={result.get('latency_ms')}ms" + ) + return True, None + else: + error = result.get("error", "Unknown error") + logger.warning(f"LLM health check failed: {error}") + return False, f"LLM service unavailable: {error}" + except Exception as e: + logger.error(f"LLM health check error: {e}") + return False, f"Health check error: {str(e)}" + + async def analyze_similarities( + self, + filter_by_keyword: Optional[str], + include_favorites: bool, + similarity_threshold: float, + ) -> Tuple[List[MergeSuggestion], int]: + """ + Analyze knowledge entries for similarity and generate merge suggestions. + + Args: + filter_by_keyword: Only analyze knowledge with this keyword (None = all) + include_favorites: Whether to include favorite knowledge + similarity_threshold: Similarity threshold (0.0-1.0) + + Returns: + (suggestions, total_tokens_used) + """ + # Check if analysis is already in progress + if self.is_locked(): + raise RuntimeError("Knowledge analysis is already in progress. Please wait for it to complete.") + + # Set lock to prevent concurrent analysis + self.set_lock(True) + logger.info("Knowledge analysis started - lock acquired") + + try: + # 0. Check LLM availability first + llm_available, llm_error = await self.health_check() + if not llm_available: + logger.error(f"LLM service not available: {llm_error}") + raise RuntimeError( + f"Cannot analyze knowledge: LLM service is not available. " + f"{llm_error}" + ) + except Exception as e: + # Release lock on any error during initialization + self.set_lock(False) + logger.info("Knowledge analysis initialization failed - lock released") + raise + + # 1. Fetch knowledge from database + knowledge_list = await self._fetch_knowledge( + filter_by_keyword, include_favorites + ) + + if len(knowledge_list) < 2: + logger.info("Not enough knowledge entries to analyze") + self.set_lock(False) + logger.info("Knowledge analysis - not enough data - lock released") + return [], 0 + + # 2. Group by keywords (tag-based categorization) + grouped = self._group_by_keywords(knowledge_list) + + # 3. Analyze each group with LLM + all_suggestions = [] + total_tokens = 0 + + try: + for keyword, group in grouped.items(): + if len(group) < 2: + continue # Skip groups with single item + + logger.info(f"Analyzing group '{keyword}' with {len(group)} entries") + suggestions, tokens = await self._analyze_group(group, similarity_threshold) + all_suggestions.extend(suggestions) + total_tokens += tokens + + logger.info(f"Analysis completed - lock will be released, found {len(all_suggestions)} suggestions") + return all_suggestions, total_tokens + except Exception as e: + logger.error(f"Analysis failed: {e}", exc_info=True) + raise + finally: + # Always release lock when done (success or error) + self.set_lock(False) + logger.info("Knowledge analysis finished - lock released") + + async def _fetch_knowledge( + self, filter_by_keyword: Optional[str], include_favorites: bool + ) -> List[Dict[str, Any]]: + """Fetch knowledge entries based on filter criteria""" + all_knowledge = await self.knowledge_repo.get_list(include_deleted=False) + + filtered = all_knowledge + + # Filter by keyword + if filter_by_keyword: + filtered = [k for k in filtered if filter_by_keyword in k.get("keywords", [])] + + # Filter favorites + if not include_favorites: + filtered = [k for k in filtered if not k.get("favorite", False)] + + return filtered + + def _group_by_keywords( + self, knowledge_list: List[Dict[str, Any]] + ) -> Dict[str, List[Dict[str, Any]]]: + """ + Group knowledge by primary keyword (first keyword). + Knowledge without keywords goes to 'untagged' group. + """ + groups: Dict[str, List[Dict[str, Any]]] = {} + + for k in knowledge_list: + keywords = k.get("keywords", []) + primary_keyword = keywords[0] if keywords else "untagged" + if primary_keyword not in groups: + groups[primary_keyword] = [] + groups[primary_keyword].append(k) + + return groups + + async def _analyze_group( + self, group: List[Dict[str, Any]], threshold: float + ) -> Tuple[List[MergeSuggestion], int]: + """ + Use LLM to analyze similarity within a group and generate merge suggestions. + + Strategy: + 1. Send group to LLM with prompt asking for similarity analysis + 2. LLM returns clusters of similar knowledge + 3. For each cluster, LLM generates merged title/description + """ + # Build prompt with knowledge details + knowledge_json = json.dumps( + [ + { + "id": k.get("id"), + "title": k.get("title"), + "description": k.get("description"), + "keywords": k.get("keywords", []), + } + for k in group + ], + ensure_ascii=False, + indent=2, + ) + + # Build messages with template variables + messages = self.prompt_manager.build_messages( + category="knowledge_merge_analysis", + prompt_type="user_prompt_template", + knowledge_json=knowledge_json, + threshold=threshold, + ) + + # Get config params + config_params = self.prompt_manager.get_config_params("knowledge_merge_analysis") + max_tokens = config_params.get("max_tokens", 4000) + temperature = config_params.get("temperature", 0.3) + + try: + # Call LLM + response = await self.llm_manager.chat_completion( + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + response_format={"type": "json_object"}, + ) + + # Parse response + content = response.get("content", "") + usage = response.get("usage", {}) + tokens_used = usage.get("total_tokens", 0) + + try: + result = json.loads(content) + suggestions = self._parse_llm_suggestions(result, group, tokens_used) + return suggestions, tokens_used + except json.JSONDecodeError as e: + logger.error(f"Failed to parse LLM response: {e}") + logger.error(f"Response content: {content}") + return [], tokens_used + + except Exception as e: + logger.error(f"Failed to call LLM for group analysis: {e}", exc_info=True) + return [], 0 + + def _parse_llm_suggestions( + self, llm_result: Dict, group: List[Dict[str, Any]], tokens_used: int + ) -> List[MergeSuggestion]: + """ + Parse LLM response into MergeSuggestion objects. + + Expected LLM response format: + { + "merge_clusters": [ + { + "knowledge_ids": ["id1", "id2", "id3"], + "merged_title": "...", + "merged_description": "...", + "merged_keywords": ["tag1", "tag2"], + "similarity_score": 0.85, + "merge_reason": "These entries discuss the same topic..." + } + ] + } + """ + suggestions = [] + + for idx, cluster in enumerate(llm_result.get("merge_clusters", [])): + # Validate required fields + if not cluster.get("knowledge_ids"): + logger.warning(f"Cluster {idx} missing knowledge_ids, skipping") + continue + + # Collect keywords from all knowledge in cluster + all_keywords = set() + for kid in cluster["knowledge_ids"]: + k = next((k for k in group if k.get("id") == kid), None) + if k: + keywords = k.get("keywords", []) + if keywords: + all_keywords.update(keywords) + + # Use LLM-provided keywords if available, otherwise use collected keywords + merged_keywords = cluster.get("merged_keywords", list(all_keywords)) + + suggestion = MergeSuggestion( + group_id=f"merge_{uuid.uuid4().hex[:8]}_{int(datetime.now().timestamp())}", + knowledge_ids=cluster["knowledge_ids"], + merged_title=cluster.get("merged_title", "Merged Knowledge"), + merged_description=cluster.get("merged_description", ""), + merged_keywords=merged_keywords, + similarity_score=cluster.get("similarity_score", 0.0), + merge_reason=cluster.get("merge_reason", "Similar content detected"), + estimated_tokens=tokens_used // len(llm_result.get("merge_clusters", [])), + ) + suggestions.append(suggestion) + + return suggestions + + async def execute_merge( + self, merge_groups: List[MergeGroup] + ) -> List[MergeResult]: + """ + Execute approved merge operations. + + For each group: + 1. Create new merged knowledge entry + 2. Soft-delete source knowledge entries + 3. Record merge history (optional) + """ + results = [] + + for group in merge_groups: + try: + # Create merged knowledge + merged_id = f"k_{uuid.uuid4().hex}" + + # Fetch source knowledge to check favorites + all_knowledge = await self.knowledge_repo.get_list(include_deleted=False) + sources = [k for k in all_knowledge if k.get("id") in group.knowledge_ids] + + # Check if any source is favorite + is_favorite = any(k.get("favorite", False) for k in sources) + + # Create new knowledge + await self.knowledge_repo.save( + knowledge_id=merged_id, + title=group.merged_title, + description=group.merged_description, + keywords=group.merged_keywords, + source_action_id=None, # No single source + favorite=is_favorite and group.keep_favorite, + ) + + logger.info(f"Created merged knowledge: {merged_id}") + + # Hard-delete source knowledge (permanent deletion for merge operation) + deleted_ids = [] + for kid in group.knowledge_ids: + try: + await self.knowledge_repo.hard_delete(kid) + deleted_ids.append(kid) + logger.info(f"Hard deleted source knowledge: {kid}") + except Exception as e: + logger.error(f"Failed to hard delete knowledge {kid}: {e}") + + # Record history (if implemented) + # await self._record_merge_history(merged_id, group.knowledge_ids, group.merge_reason) + + results.append( + MergeResult( + group_id=group.group_id, + merged_knowledge_id=merged_id, + deleted_knowledge_ids=deleted_ids, + success=True, + ) + ) + + except Exception as e: + logger.error(f"Failed to merge group {group.group_id}: {e}", exc_info=True) + results.append( + MergeResult( + group_id=group.group_id, + merged_knowledge_id="", + deleted_knowledge_ids=[], + success=False, + error=str(e), + ) + ) + + return results diff --git a/clock.html b/clock.html new file mode 100644 index 0000000..55118c0 --- /dev/null +++ b/clock.html @@ -0,0 +1,42 @@ + + + + + + iDO Clock + + + +
+ + + diff --git a/docs/developers/architecture/three-layer-design.md b/docs/developers/architecture/three-layer-design.md index e6107c2..2fb29e3 100644 --- a/docs/developers/architecture/three-layer-design.md +++ b/docs/developers/architecture/three-layer-design.md @@ -584,7 +584,7 @@ Each layer is independently configurable: # config.toml [monitoring] # Perception layer -capture_interval = 1 # seconds +capture_interval = 0.2 # seconds (5 screenshots per second) window_size = 20 # seconds [processing] # Processing layer diff --git a/docs/user-guide/faq.md b/docs/user-guide/faq.md index 9791a49..a098796 100644 --- a/docs/user-guide/faq.md +++ b/docs/user-guide/faq.md @@ -17,11 +17,8 @@ iDO itself is free and open source. However, you need to provide your own LLM AP Currently: - ✅ **macOS** 13 (Ventura) or later - -Coming soon: - -- ⏳ **Windows** 10 or later -- ⏳ **Linux** (Ubuntu 20.04+) +- ✅ **Windows** 10 or later +- ✅ **Linux** (Ubuntu 20.04+ or equivalent) ### Is my data private? @@ -66,7 +63,7 @@ On macOS, iDO requires: **Accessibility** - To monitor keyboard and mouse events **Screen Recording** - To capture screenshots -You'll be prompted to grant these on first run. See [Installation Guide](./installation.md#1-grant-system-permissions) for details. +You'll be prompted to grant these on first run. See [Installation Guide](./installation.md) for details. ### Do I need an OpenAI API key? @@ -78,13 +75,13 @@ Get an OpenAI API key at: https://platform.openai.com/api-keys **Recommended**: -- **gpt-4** - Best quality, ~$0.05-0.10 per hour -- **gpt-3.5-turbo** - Good quality, ~$0.01-0.02 per hour +- **gpt-4o-mini** - Best value, good quality, ~$0.01 per hour (default) +- **gpt-4o** - Higher quality, ~$0.05-0.10 per hour **Tips**: -- Start with gpt-3.5-turbo to save costs -- Upgrade to gpt-4 if summaries aren't accurate enough +- Start with gpt-4o-mini (default) for best value +- Upgrade to gpt-4o if you need higher quality - You can change models anytime in Settings ## Usage @@ -93,7 +90,7 @@ Get an OpenAI API key at: https://platform.openai.com/api-keys iDO uses a three-layer approach: -1. **Perception Layer**: Captures keyboard, mouse, and screenshots every 1-3 seconds +1. **Perception Layer**: Captures keyboard, mouse, and screenshots (default: 0.2 seconds) 2. **Processing Layer**: Filters noise and uses LLM to create meaningful activity summaries 3. **Consumption Layer**: Displays activities and generates task recommendations @@ -122,7 +119,7 @@ Storage varies based on your settings: **Factors**: -- Capture interval (1s = more screenshots) +- Capture interval (0.2s = 5 screenshots/sec/monitor) - Image quality (85% is default) - Number of monitors - Image optimization (reduces duplicates) @@ -161,7 +158,7 @@ Storage varies based on your settings: 1. ✅ Permissions granted (Accessibility + Screen Recording) 2. ✅ At least one monitor enabled in Settings -3. ✅ Capture is running (Dashboard shows "Running") +3. ✅ Check system status in sidebar **Solutions**: @@ -206,7 +203,7 @@ Storage varies based on your settings: - Verify LLM connection (Settings → Test Connection) - Check API key has available credits -- Try a different model (gpt-4 vs gpt-3.5-turbo) +- Try a different model (gpt-4o vs gpt-4o-mini) - Review logs for errors ### iDO is using too much CPU/RAM @@ -296,13 +293,13 @@ View the source code: https://github.com/UbiquantAI/iDO **Differences**: -| Feature | iDO | Rewind.ai | -| ------------ | ---------------------------- | ------------- | -| **Source** | Open source | Closed source | -| **Privacy** | Local-only | Cloud option | -| **LLM** | Bring your own | Built-in | -| **Cost** | Free (+ API costs) | Subscription | -| **Platform** | macOS (Linux/Windows coming) | macOS only | +| Feature | iDO | Rewind.ai | +| ------------ | --------------------- | ------------- | +| **Source** | Open source | Closed source | +| **Privacy** | Local-only | Cloud option | +| **LLM** | Bring your own | Built-in | +| **Cost** | Free (+ API costs) | Subscription | +| **Platform** | macOS, Windows, Linux | macOS only | ## Features & Roadmap @@ -340,12 +337,12 @@ See our roadmap: https://github.com/UbiquantAI/iDO/issues **Planned features**: -- Windows and Linux support - App-specific filtering - Automatic data retention policies - Task manager integrations - Custom agents - Team/multi-user support +- Mobile companion app ### Can I build custom agents? @@ -369,13 +366,13 @@ See [Backend Development Guide](../developers/guides/backend/README.md#agent-sys **Costs depend on**: -- LLM model (gpt-4 vs gpt-3.5-turbo) +- LLM model (gpt-4o vs gpt-4o-mini) - Capture interval (more screenshots = more API calls) - Activity complexity **Tips to reduce costs**: -- Use gpt-3.5-turbo instead of gpt-4 +- Use gpt-4o-mini (default) for best value - Increase capture interval to 2-3 seconds - Disable capture when not needed diff --git a/docs/user-guide/features.md b/docs/user-guide/features.md index 9e87273..1515be4 100644 --- a/docs/user-guide/features.md +++ b/docs/user-guide/features.md @@ -11,306 +11,272 @@ iDO is a local-first AI desktop copilot that: - **✅ Recommends tasks** - Suggests what to do next based on your patterns - **🔒 Keeps data private** - Everything stays on your device -## Core Features +## Main Features -### 1. Activity Timeline +### 1. Pomodoro Focus Mode -**What it does**: Automatically captures and organizes your computer activities - -**How it works**: -- Monitors keyboard and mouse events -- Takes periodic screenshots -- Groups related events into activities -- Uses AI to generate descriptive titles +**What it does**: Focus Mode with intelligent Pomodoro timer for capturing and analyzing your focused work **How to use**: -1. Navigate to **Activity Timeline** in the sidebar -2. Browse activities grouped by date -3. Click any activity to see details: - - Screenshots from that time period - - Event descriptions - - Duration and timestamps -**Benefits**: -- Review what you worked on -- Find information from past activities -- Track how you spend time +1. Navigate to **Pomodoro** in the sidebar +2. Left panel shows your scheduled todos - click one to select +3. Or enter a custom task description manually +4. Choose a mode: Classic (25/5), Deep (50/10), Quick (15/3), Focus (90/15) +5. Click **Start** to begin + +**Interface**: -### 2. AI-Powered Summaries +- **Left sidebar**: Scheduled todos with count badge for quick selection +- **Main panel**: Timer display with mode selector and task input +- **Task association**: Link sessions to AI-generated todos or manual intent +- **Phase display**: Shows current phase (Work/Break) and round progress -**What it does**: Uses LLMs to create human-readable activity descriptions +**Features**: -**How it works**: -- Analyzes screenshots and event data -- Generates concise, meaningful titles -- Groups similar events together -- Filters out noise and interruptions +- 4 preset modes with customizable duration +- Links sessions to AI-generated todos +- Real-time countdown with circular progress +- Phase notifications (work/break transitions) +- Activity capture during work phases +- Work phase status tracking (activities captured during session) -**Example**: -Instead of seeing raw events like: -- `Mouse click at (450, 320)` -- `Keyboard input: "const foo =..."` -- `Window focus: VSCode` +### 2. Pomodoro Review -You see: -- `Writing TypeScript code in VSCode` +**What it does**: Review your focus sessions and track your productivity -**Benefits**: -- Easy-to-understand activity history -- No manual note-taking required -- Context-aware descriptions +**How to use**: -### 3. Smart Task Recommendations +1. Navigate to **Pomodoro Review** in the sidebar +2. View period statistics (weekly total, daily average, completion rate) +3. Check the weekly focus chart for trends +4. Select a date using the date picker +5. Browse sessions and click to view detailed breakdown +6. Review AI-generated focus analysis in the session dialog -**What it does**: AI agents analyze your activities and suggest tasks +**Interface**: -**How it works**: -1. Agents monitor your activity stream -2. Detect patterns and context (coding, writing, browsing, etc.) -3. Generate relevant task suggestions -4. Prioritize based on importance +- **Statistics Overview Cards**: Weekly total, focus hours, daily average, completion rate +- **Weekly Focus Chart**: Bar chart showing daily focus minutes with goal line +- **Time Period Selector**: Switch between week/month/year views +- **Date Picker**: Select specific dates to view sessions +- **Session List**: Click sessions to open detailed dialog +- **Session Detail Dialog**: Shows focus metrics, activity timeline, LLM analysis -**How to use**: -1. Navigate to **Agents** in the sidebar -2. View AI-generated task recommendations -3. Mark tasks as complete -4. See how tasks relate to specific activities +**Features**: -**Example agents**: -- **Code Review Agent**: Suggests code review tasks when you're coding -- **Documentation Agent**: Recommends writing docs after implementing features -- **Research Agent**: Proposes follow-up research based on browsing +- Period statistics with visual overview +- Activity timeline during each session +- AI-powered focus quality evaluation (strengths, weaknesses, suggestions) +- Work type analysis (deep work, distractions, focus streaks) +- Weekly focus goal tracking +- Distraction percentage analysis -**Benefits**: -- Never forget important tasks -- Context-aware reminders -- Learn from your patterns +### 3. Knowledge -### 4. Privacy-First Design +**What it does**: All long-term knowledge captured from your recent activity -**What it does**: Keeps all your data on your device +**How to use**: -**How it works**: -- All data stored in local SQLite database -- Screenshots saved to local disk -- LLM calls use your own API key -- No cloud uploads or syncing +1. Navigate to **Knowledge** in the sidebar +2. Use left sidebar to filter by category/keyword +3. Search or filter (All/Favorites/Recent) +4. Click a card to view details or edit +5. Use **Smart Merge** to find and combine similar knowledge +6. Create new notes manually -**What gets sent to LLM**: -- Screenshots (as base64 data) -- Event summaries (no raw keystrokes) -- Timestamps and window titles +**Interface**: -**What never leaves your device**: -- Raw database -- Complete keystroke logs -- Sensitive information +- **Left Sidebar**: Category filter showing keyword counts +- **Search Bar**: Full-text search across titles, descriptions, keywords +- **Filter Tabs**: All / Favorites / Recent (last 7 days) +- **Action Buttons**: Smart Merge, New Note +- **Knowledge Cards Grid**: Scrollable card list with hover actions +- **Detail Dialog**: View/edit knowledge details -**Benefits**: -- Full control over your data -- Works offline (except LLM calls) -- No subscription or vendor lock-in +**Features**: -### 5. Customizable Capture +- AI-generated from activities +- Full-text search across all cards +- Favorites with quick toggle +- Category/keyword filtering +- Smart duplicate detection and merging with configurable thresholds +- Create manual notes +- Retry dialog for LLM errors -**What it does**: Control what and how iDO captures +### 4. Todos -**Settings you can adjust**: +**What it does**: AI will automatically generate todos from your activities -**Capture Interval** -- How often screenshots are taken -- Default: 1 second -- Range: 0.5 - 5 seconds -- Lower = more detailed, higher = less disk space +**How to use**: -**Screen Selection** -- Choose which monitors to capture -- Enable/disable per monitor -- Useful for privacy (exclude personal monitor) +1. Navigate to **Todos** in the sidebar +2. Toggle between Cards View and Calendar View +3. Use left sidebar to filter by category/keyword +4. Click a todo to view details or edit +5. Drag todos to calendar to schedule +6. Click **Create Todo** to add manually +7. Send todos to Chat for agent execution -**Image Quality** -- Screenshot compression level -- Default: 85% -- Range: 50% - 100% -- Lower = smaller files, higher = better quality +**Interface**: -**Image Optimization** -- Smart screenshot deduplication -- Skips nearly-identical screenshots -- Saves disk space automatically +- **View Mode Toggle**: Switch between Cards and Calendar views +- **Left Sidebar**: Category filter +- **Cards View**: Grid of todo cards with hover actions +- **Calendar View**: Full calendar with drag-to-schedule +- **Pending Section**: Quick access to unscheduled todos (calendar view) +- **Detail Dialog**: View/edit schedule, recurrence, send to chat +- **Create Todo Dialog**: Manual todo creation -**How to configure**: -1. Open **Settings** → **Screen Capture** -2. Adjust preferences -3. Click **Save** +**Features**: -### 6. Search and Filter +- Auto-generated from activities +- Manual creation supported +- Calendar scheduling with start/end times +- Recurrence rules (daily, weekly, etc.) +- Keywords and priority levels +- Drag-and-drop to calendar +- Send to Chat for AI execution +- Linked to Pomodoro sessions +- Filter by category -**What it does**: Find past activities quickly +### 5. Diary -**Search by**: -- Keywords in activity titles -- Date ranges -- Duration -- Applications used +**What it does**: Daily journals compiled from your AI activity summaries **How to use**: -1. Go to **Activity Timeline** -2. Use the search bar at the top -3. Apply filters (date, duration, etc.) -4. Click any result to view details -### 7. Multi-Language Support +1. Navigate to **Diary** in the sidebar +2. Scroll through past diaries or use date picker +3. Click a diary card to view full content +4. Edit summaries as needed +5. Select a date and click **Generate Diary** to create new -**What it does**: Use iDO in your preferred language +**Interface**: -**Supported languages**: -- English -- 中文 (Chinese) +- **Action Bar**: Refresh, Load More, Date Picker, Generate button +- **Diary Cards**: Scrollable list of daily summaries +- **Diary Card**: Shows date, key highlights, work categories -**How to change**: -1. Open **Settings** → **Preferences** -2. Select **Language** -3. Choose your language -4. UI updates immediately +**Features**: -### 8. Theme Customization +- AI-generated daily summaries +- Scrollable history with load more +- Select specific dates to generate +- Editable content +- Key highlights extraction +- Work type categorization +- Delete diaries -**What it does**: Adjust visual appearance +### 6. Chat -**Available themes**: -- **Light Mode**: Bright interface -- **Dark Mode**: Easy on the eyes -- **System**: Match OS theme +**What it does**: Conversational interface about your activity history with streaming responses and image support -**How to change**: -1. Open **Settings** → **Appearance** -2. Select theme -3. Changes apply immediately - -## Interface Overview - -### Dashboard +**How to use**: -**What you'll see**: -- System status (Running / Stopped) -- Active LLM model -- Statistics (events captured, activities created) -- Recent activity summary +1. Navigate to **Chat** in the sidebar +2. Select an existing conversation or create a new one +3. Type a question about your activities +4. Get streaming responses grounded in your data +5. Optionally drag & drop images to analyze -**Actions**: -- Start/stop activity capture -- View system health -- Quick access to settings +**Interface**: -### Activity Timeline +- **Left Sidebar**: Conversation list with new/delete actions (desktop) +- **Mobile Overlay**: Slide-out conversation list (mobile) +- **Message Area**: Scrollable message history with streaming support +- **Activity Context**: Shown when conversation is linked to an activity +- **Input Area**: Text input with image drag-drop, model selector -**Layout**: -- Chronological list grouped by date -- Sticky date headers -- Activity cards with: - - Title - - Duration - - Thumbnail screenshot - - Timestamp +**Features**: -**Interactions**: -- Click activity → View details -- Scroll → Auto-load more -- Search → Filter results +- Context-aware responses grounded in your activity data +- Streaming AI responses for real-time feedback +- Image drag-and-drop support (PNG, JPG, GIF) +- Model selection per conversation +- Auto-generated conversation titles +- Activity context linking +- Send todos/knowledge to chat from other pages +- Retry failed responses +- Cancel streaming responses -### Activity Details +**Example questions**: -**What you'll see**: -- Full activity title and description -- All screenshots from that period -- Event timeline -- Related tasks (if any) +- "What did I work on yesterday?" +- "How much time did I spend coding this week?" +- "What were my main activities?" +- "Summarize my focus sessions from last week" -**Actions**: -- Navigate between screenshots -- Export activity data -- Generate tasks from this activity +### 7. Dashboard -### Agents View +**What it does**: View Token usage and Agent task statistics -**What you'll see**: -- List of AI-generated tasks -- Task status (pending, completed) -- Priority levels -- Source activities +**How to use**: -**Actions**: -- Mark task as complete -- View source activity -- Dismiss tasks +1. Navigate to **Dashboard** in the sidebar +2. Filter by all models or select a specific model +3. View token usage, API calls, and cost metrics +4. Check usage trends over time with interactive chart + +**Interface**: + +- **Model Filter**: Dropdown to select all models or specific model +- **LLM Stats Cards Grid**: + - Total Tokens (with description) + - Total API Calls (with description) + - Total Cost (single model view) + - Models Used (all models view) + - Model Price (single model view) +- **Usage Trend Chart**: Interactive chart with dimension and range selectors +- **Trend Dimensions**: Focus minutes, LLM tokens, API calls +- **Trend Ranges**: Week, Month, Year + +**Features**: + +- Real-time LLM usage statistics +- Token count and API call tracking +- Cost analysis per model +- Usage trend visualization +- Model performance comparison +- Currency-aware cost display +- Responsive layout for all screen sizes + +**Metrics tracked**: + +- Total tokens processed +- Total API calls made +- Total cost (currency-formatted) +- Models used in selected period +- Per-million-token pricing (input/output) -### Settings +## Interface Overview -**Categories**: -- **LLM Configuration**: API key, model selection -- **Screen Capture**: Monitor selection, intervals -- **Preferences**: Language, theme, notifications -- **Privacy**: Data retention, export options -- **About**: Version info, licenses +### Sidebar Navigation -## Data Management +| Icon | Page | Description | +| ------------- | --------------- | ------------------------------------- | +| Timer | Pomodoro | Focus timer with task linking | +| History | Pomodoro Review | Session history and metrics | +| BookOpen | Knowledge | AI-generated knowledge cards | +| CheckSquare | Todos | AI-generated tasks | +| NotebookPen | Diary | Daily work summaries | +| MessageSquare | Chat | Conversational AI about your history | +| BarChart | Dashboard | Statistics and usage tracking | +| Settings | Settings | App configuration (bottom of sidebar) | -### Storage Location +### Data Storage All data is stored locally: + - **macOS**: `~/.config/ido/` - **Windows**: `%APPDATA%\ido\` - **Linux**: `~/.config/ido/` -### Data Retention - -**Automatic cleanup** (coming soon): -- Screenshots older than 30 days (configurable) -- Completed tasks older than 90 days -- Logs older than 7 days - -**Manual cleanup**: -1. **Settings** → **Privacy** -2. Choose what to delete -3. Confirm deletion +Contains: -### Export Data - -**Export options** (coming soon): -- Export activities as JSON -- Export screenshots as ZIP -- Export database backup - -## Tips for Best Results - -### 1. Configure Permissions Properly - -**macOS**: Grant both Accessibility and Screen Recording permissions for full functionality - -### 2. Choose the Right LLM Model - -- **gpt-4**: Best quality summaries, slower, more expensive -- **gpt-3.5-turbo**: Good quality, faster, cheaper -- **Other models**: Experiment with compatible OpenAI-style APIs - -### 3. Adjust Capture Interval - -- **Fast work** (coding, design): 1 second intervals -- **General use**: 2-3 second intervals -- **Browsing/reading**: 3-5 second intervals - -### 4. Manage Disk Space - -- Enable **Image Optimization** to reduce duplicates -- Lower **Image Quality** if disk space is limited -- Disable capture on unused monitors - -### 5. Review Activities Regularly - -- Check timeline daily to verify accuracy -- Dismiss irrelevant tasks -- Adjust settings based on results +- `ido.db` - SQLite database +- `screenshots/` - Captured screenshots +- `logs/` - Application logs ## Privacy Features @@ -329,27 +295,18 @@ All data is stored locally: ⚠️ **Database is unencrypted** - Store in encrypted volume if needed ⚠️ **Logs may contain info** - Review before sharing -## Limitations - -### Current Limitations - -- **macOS only** (Windows/Linux coming soon) -- **Single user** (no multi-user accounts) -- **Local only** (no cloud sync) -- **Requires LLM API** (costs apply for API calls) - -### Performance Considerations +## Performance - **CPU usage**: ~2-5% during capture - **Memory**: ~200-500 MB RAM - **Disk**: ~100-500 MB per day (varies by interval and quality) -- **LLM costs**: $0.01-0.10 per hour of activity (varies by model) +- **Screenshot interval**: 0.2 seconds (5 screenshots/sec/monitor) ## Next Steps -- **[Read FAQ](./faq.md)** - Common questions +- **[Installation Guide](./installation.md)** - Set up iDO +- **[FAQ](./faq.md)** - Common questions - **[Troubleshooting](./troubleshooting.md)** - Fix issues -- **[Installation Guide](./installation.md)** - Re-visit setup ## Need Help? diff --git a/docs/user-guide/installation.md b/docs/user-guide/installation.md index c7b4d6f..fabe2a4 100644 --- a/docs/user-guide/installation.md +++ b/docs/user-guide/installation.md @@ -7,12 +7,12 @@ This guide will help you download and install iDO on your computer. ### Supported Platforms - **macOS**: 13 (Ventura) or later -- **Windows**: 10 or later (Coming soon) -- **Linux**: Ubuntu 20.04+ or equivalent (Coming soon) +- **Windows**: 10 or later +- **Linux**: Ubuntu 20.04+ or equivalent ### Minimum Hardware -- **CPU**: 64-bit processor +- **CPU**: 64-bit processor (Apple Silicon or Intel) - **RAM**: 4 GB minimum, 8 GB recommended - **Disk Space**: 500 MB for application, plus space for activity data - **Display**: 1280x720 minimum resolution @@ -29,14 +29,14 @@ Download the latest version from GitHub: #### macOS -- Download `iDO_x.x.x_aarch64.dmg` (Apple Silicon - M1/M2/M3) +- Download `iDO_x.x.x_aarch64.dmg` (Apple Silicon - M1/M2/M3/M4) - Download `iDO_x.x.x_x64.dmg` (Intel Mac) -#### Windows (Coming Soon) +#### Windows - `iDO_x.x.x_x64_en-US.msi` - Windows installer -#### Linux (Coming Soon) +#### Linux - `ido_x.x.x_amd64.deb` - Debian/Ubuntu - `ido-x.x.x-1.x86_64.rpm` - Fedora/RHEL @@ -60,23 +60,21 @@ Download the latest version from GitHub: 3. Click **Open Anyway** 4. Confirm by clicking **Open** -**Unsigned build workaround**: If the downloaded build remains blocked after approving it in Privacy & Security, clear the quarantine flag and add an ad-hoc signature: +**Unsigned build workaround**: If the downloaded build remains blocked after approving it in Privacy & Security, clear the quarantine flag: ```bash xattr -cr /Applications/iDO.app codesign -s - -f /Applications/iDO.app ``` -Then launch iDO again from Applications. - -### Windows (Coming Soon) +### Windows 1. **Download** the `.msi` installer 2. **Double-click** the installer 3. **Follow** the installation wizard 4. **Launch** iDO from the Start Menu -### Linux (Coming Soon) +### Linux #### Debian/Ubuntu (.deb) @@ -100,71 +98,69 @@ chmod +x ido_x.x.x_amd64.AppImage ## First Run Setup -When you first launch iDO, you'll need to complete some setup steps: +When you first launch iDO, you'll go through an initial setup wizard with 6 steps: -### 1. Grant System Permissions +### 1. Welcome -#### macOS Permissions +Get started with iDO and learn about key features. -iDO requires the following permissions: +### 2. Screen Selection -**Accessibility Permission** (Required) +Choose which monitors to capture: -- Allows iDO to monitor keyboard and mouse events -- Go to **System Settings** → **Privacy & Security** → **Accessibility** -- Enable iDO in the list +- View all detected displays +- Enable/disable specific monitors +- By default, only the primary monitor is enabled -**Screen Recording Permission** (Required) +**Default Settings**: +- **Capture interval**: 0.2 seconds (5 screenshots per second per monitor) +- **Image quality**: 85% +- **Smart deduplication**: Enabled -- Allows iDO to capture screenshots -- Go to **System Settings** → **Privacy & Security** → **Screen Recording** -- Enable iDO in the list +### 3. LLM Provider Configuration -iDO will guide you through granting these permissions on first run. +iDO uses an LLM (Large Language Model) to analyze your activities: -### 2. Configure LLM Provider +1. Enter your API endpoint and key +2. Select a model (default: gpt-4o-mini) +3. Test the connection -iDO uses an LLM (Large Language Model) to analyze your activities: +**Supported Providers**: -1. **Open Settings** → **LLM Configuration** -2. **Choose Provider**: OpenAI (recommended) or compatible API -3. **Enter API Key**: Your OpenAI API key - - Get one at https://platform.openai.com/api-keys -4. **Select Model**: - - `gpt-4` - Most capable (recommended) - - `gpt-3.5-turbo` - Faster and cheaper -5. **Test Connection**: Click to verify it works +- OpenAI (GPT-4, GPT-3.5-Turbo) +- Anthropic (Claude) +- Local models (Ollama, LM Studio, etc.) +- Any OpenAI-compatible API **Privacy Note**: Your API key is stored locally and used only to make LLM requests on your behalf. iDO does not send data to any iDO servers. -### 3. Configure Screen Capture (Optional) +### 4. Grant System Permissions -Choose which monitors to capture: +#### macOS Permissions + +iDO requires the following permissions: -1. **Open Settings** → **Screen Capture** -2. **View Monitors**: See all detected displays -3. **Toggle On/Off**: Enable/disable specific monitors -4. **Save**: Apply your preferences +**Accessibility Permission** (Required) -By default, only the primary monitor is enabled. +- Allows iDO to monitor keyboard and mouse events +- Go to **System Settings** → **Privacy & Security** → **Accessibility** +- Enable iDO in the list -### 4. Adjust Preferences (Optional) +**Screen Recording Permission** (Required) -Fine-tune iDO to your liking: +- Allows iDO to capture screenshots +- Go to **System Settings** → **Privacy & Security** → **Screen Recording** +- Enable iDO in the list -- **Capture Interval**: How often to take screenshots (default: 1 second) -- **Image Quality**: Balance quality vs disk space (default: 85%) -- **Language**: English or 中文 (Chinese) -- **Theme**: Light or Dark mode +The app will guide you through granting these permissions. -## Verify Installation +### 5. Set Goals (Optional) -To verify iDO is working correctly: +Define your focus goals and preferences for AI-generated tasks. -1. **Check Dashboard**: You should see system status as "Running" -2. **Use Your Computer**: Browse, type, etc. for 1-2 minutes -3. **View Timeline**: Navigate to Activity Timeline -4. **See Activities**: You should see captured activities with screenshots +### 6. Complete + +You're ready to start using iDO! ## Data Storage @@ -188,13 +184,13 @@ This directory contains: 2. **Drag** iDO from Applications to Trash 3. **Remove data** (optional): Delete `~/.config/ido/` -### Windows (Coming Soon) +### Windows 1. **Control Panel** → **Programs** → **Uninstall a program** 2. Select iDO and click **Uninstall** 3. **Remove data** (optional): Delete `%APPDATA%\ido\` -### Linux (Coming Soon) +### Linux ```bash # Debian/Ubuntu @@ -207,6 +203,17 @@ sudo rpm -e ido rm -rf ~/.config/ido/ ``` +## Verify Installation + +To verify iDO is working correctly: + +1. **Complete the setup wizard** +2. **Grant permissions** when prompted +3. **Configure your LLM** model +4. **Start using your computer** for a few minutes +5. **Navigate to Insights** → **Knowledge** or **Todos** +6. **Check for AI-generated content** from your activities + ## Troubleshooting ### App Won't Launch @@ -227,13 +234,14 @@ rm -rf ~/.config/ido/ ### LLM Connection Failed -**Issue**: Test connection fails +**Issue**: Model test connection fails **Solutions**: 1. Verify your API key is correct 2. Check your internet connection -3. Try a different model (e.g., switch from gpt-4 to gpt-3.5-turbo) +3. Verify the model endpoint is accessible +4. Try a different model ### High CPU/Memory Usage @@ -241,9 +249,10 @@ rm -rf ~/.config/ido/ **Solutions**: -1. Increase capture interval (Settings → 2-3 seconds instead of 1) +1. Increase capture interval (Settings → 2-5 seconds instead of 0.2) 2. Lower image quality (Settings → 70% instead of 85%) 3. Disable unused monitors +4. Reduce number of monitors For more troubleshooting help, see the [Troubleshooting Guide](./troubleshooting.md). diff --git a/docs/user-guide/troubleshooting.md b/docs/user-guide/troubleshooting.md index 3188920..5f3ebdc 100644 --- a/docs/user-guide/troubleshooting.md +++ b/docs/user-guide/troubleshooting.md @@ -160,7 +160,7 @@ pnpm check-i18n ```toml # Edit backend/config/config.toml [monitoring] - capture_interval = 1 # Try lower value + capture_interval = 0.2 # Default is 0.2s (5 screenshots/sec) ``` ### LLM Connection Failed @@ -309,7 +309,7 @@ codesign --force --deep --sign "Developer ID" ./target/release/bundle/macos/iDO. # 1. Reduce capture interval # Edit backend/config/config.toml [monitoring] -capture_interval = 2 # Increase from 1 to 2 seconds +capture_interval = 1 # Increase from 0.2 to 1+ seconds # 2. Disable screenshot capture temporarily # Settings → Screen Capture → Disable all monitors diff --git a/package.json b/package.json index 1a31895..3ce656b 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@hookform/resolvers": "^5.2.2", "@icons-pack/react-simple-icons": "^13.8.0", "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -55,6 +56,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-use-controllable-state": "^1.2.2", "@tailwindcss/vite": "^4.1.17", + "@tanstack/react-query": "^5.90.13", "@tauri-apps/api": "^2.9.0", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-http": "~2.4.4", @@ -67,6 +69,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "framer-motion": "12.23.26", "harden-react-markdown": "^1.1.5", "highlight.js": "^11.11.1", "i18next": "^25.6.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76667e1..78395a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@radix-ui/react-alert-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-collapsible': + specifier: ^1.1.12 + version: 1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/react-context-menu': specifier: ^2.2.16 version: 2.2.16(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -68,6 +71,9 @@ importers: '@tailwindcss/vite': specifier: ^4.1.17 version: 4.1.17(rolldown-vite@7.2.2(@types/node@22.19.0)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1)) + '@tanstack/react-query': + specifier: ^5.90.13 + version: 5.90.13(react@19.2.0) '@tauri-apps/api': specifier: ^2.9.0 version: 2.9.0 @@ -104,6 +110,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + framer-motion: + specifier: 12.23.26 + version: 12.23.26(react-dom@19.2.0(react@19.2.0))(react@19.2.0) harden-react-markdown: specifier: ^1.1.5 version: 1.1.5(react-markdown@10.1.0(@types/react@19.2.2)(react@19.2.0))(react@19.2.0) @@ -957,7 +966,7 @@ packages: resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} '@radix-ui/primitive@1.1.3': - resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==, tarball: https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz} '@radix-ui/react-alert-dialog@1.1.15': resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} @@ -985,6 +994,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==, tarball: https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collection@1.1.7': resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} peerDependencies: @@ -999,7 +1021,7 @@ packages: optional: true '@radix-ui/react-compose-refs@1.1.2': - resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==, tarball: https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1021,7 +1043,7 @@ packages: optional: true '@radix-ui/react-context@1.1.2': - resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==, tarball: https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1100,7 +1122,7 @@ packages: optional: true '@radix-ui/react-id@1.1.1': - resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==, tarball: https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1174,7 +1196,7 @@ packages: optional: true '@radix-ui/react-presence@1.1.5': - resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==, tarball: https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1187,7 +1209,7 @@ packages: optional: true '@radix-ui/react-primitive@2.1.3': - resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==, tarball: https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -1291,7 +1313,7 @@ packages: optional: true '@radix-ui/react-slot@1.2.3': - resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==, tarball: https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1384,7 +1406,7 @@ packages: optional: true '@radix-ui/react-use-layout-effect@1.1.1': - resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==, tarball: https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz} peerDependencies: '@types/react': '*' react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc @@ -1657,6 +1679,14 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 + '@tanstack/query-core@5.90.13': + resolution: {integrity: sha512-3VzxSkv4ojPPHu0WfOwZ/W5CuN7evAXPzQS+Py2glGxk59Wp+k2T/wgRfrgXAcX1kCTvD9RYUcVEHkMXkEN5jw==, tarball: https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.13.tgz} + + '@tanstack/react-query@5.90.13': + resolution: {integrity: sha512-i6DY9wnghE0ghHJfDrnnFNatn4CNBzMZv4xPzKB7Lb9zMAoImAxPKoGK9gLOm79aopDa07p6ytlFFWotvwj3DQ==, tarball: https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.13.tgz} + peerDependencies: + react: ^18 || ^19 + '@tauri-apps/api@2.9.0': resolution: {integrity: sha512-qD5tMjh7utwBk9/5PrTA/aGr3i5QaJ/Mlt7p8NilQ45WgbifUNPyKWsA63iQ8YfQq6R8ajMapU+/Q8nMcPRLNw==} @@ -2492,6 +2522,20 @@ packages: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} + framer-motion@12.23.26: + resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==, tarball: https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fs-extra@8.1.0: resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} engines: {node: '>=6 <7 || >=8'} @@ -3228,6 +3272,12 @@ packages: resolution: {integrity: sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==, tarball: https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz} engines: {node: '>=0.10.0'} + motion-dom@12.23.23: + resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==, tarball: https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz} + + motion-utils@12.23.6: + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==, tarball: https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -5003,6 +5053,22 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0) @@ -5644,6 +5710,13 @@ snapshots: tailwindcss: 4.1.17 vite: rolldown-vite@7.2.2(@types/node@22.19.0)(esbuild@0.25.12)(jiti@2.6.1)(tsx@4.20.6)(yaml@2.8.1) + '@tanstack/query-core@5.90.13': {} + + '@tanstack/react-query@5.90.13(react@19.2.0)': + dependencies: + '@tanstack/query-core': 5.90.13 + react: 19.2.0 + '@tauri-apps/api@2.9.0': {} '@tauri-apps/cli-darwin-arm64@2.9.4': @@ -6465,6 +6538,15 @@ snapshots: format@0.2.2: {} + framer-motion@12.23.26(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + motion-dom: 12.23.23 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + fs-extra@8.1.0: dependencies: graceful-fs: 4.2.11 @@ -7475,6 +7557,12 @@ snapshots: modify-values@1.0.1: {} + motion-dom@12.23.23: + dependencies: + motion-utils: 12.23.6 + + motion-utils@12.23.6: {} + ms@2.1.3: {} nano-spawn@2.0.0: {} diff --git a/scripts/assign_activity_phases.py b/scripts/assign_activity_phases.py new file mode 100755 index 0000000..3cad802 --- /dev/null +++ b/scripts/assign_activity_phases.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +""" +Assign work phases to existing Pomodoro activities that don't have phases + +This script finds all activities linked to Pomodoro sessions but missing +work_phase assignment, and automatically assigns the correct phase based +on the activity's start time and the session's phase timeline. +""" + +import asyncio +import sys +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Dict, List, Optional + +# Add backend to path +backend_path = Path(__file__).parent.parent / "backend" +sys.path.insert(0, str(backend_path)) + +from core.db import get_db +from core.logger import get_logger + +logger = get_logger(__name__) + + +def calculate_phase_timeline(session: Dict[str, Any]) -> List[Dict[str, Any]]: + """ + Calculate work phase timeline for a session + + Returns only work phases (not breaks) with their time ranges + """ + start_time = datetime.fromisoformat(session["start_time"]) + work_duration = session.get("work_duration_minutes", 25) + break_duration = session.get("break_duration_minutes", 5) + completed_rounds = session.get("completed_rounds", 0) + + timeline = [] + current_time = start_time + + for round_num in range(1, completed_rounds + 1): + # Work phase + work_end = current_time + timedelta(minutes=work_duration) + timeline.append( + { + "phase_number": round_num, + "start_time": current_time.isoformat(), + "end_time": work_end.isoformat(), + } + ) + current_time = work_end + + # Add break duration to move to next work phase + current_time = current_time + timedelta(minutes=break_duration) + + return timeline + + +def determine_work_phase( + activity_start_time: str, phase_timeline: List[Dict[str, Any]] +) -> Optional[int]: + """ + Determine which work phase an activity belongs to based on its start time + + Args: + activity_start_time: ISO format timestamp of activity start + phase_timeline: List of work phase dictionaries with start_time, end_time + + Returns: + Work phase number (1-based) or None if no phases available + """ + if not phase_timeline: + return None + + try: + activity_time = datetime.fromisoformat(activity_start_time) + + # First, check if activity falls within any work phase + for phase in phase_timeline: + phase_start = datetime.fromisoformat(phase["start_time"]) + phase_end = datetime.fromisoformat(phase["end_time"]) + + if phase_start <= activity_time <= phase_end: + return phase["phase_number"] + + # Activity doesn't fall within any work phase + # Assign to nearest work phase + nearest_phase = None + min_distance = None + + for phase in phase_timeline: + phase_start = datetime.fromisoformat(phase["start_time"]) + phase_end = datetime.fromisoformat(phase["end_time"]) + + # Calculate distance from activity to this phase + if activity_time < phase_start: + distance = (phase_start - activity_time).total_seconds() + elif activity_time > phase_end: + distance = (activity_time - phase_end).total_seconds() + else: + # This shouldn't happen as we already checked above + return phase["phase_number"] + + if min_distance is None or distance < min_distance: + min_distance = distance + nearest_phase = phase["phase_number"] + + if nearest_phase: + logger.debug( + f"Activity at {activity_start_time} doesn't fall in any work phase, " + f"assigning to nearest phase: {nearest_phase}" + ) + + return nearest_phase + + except Exception as e: + logger.error(f"Error determining work phase: {e}", exc_info=True) + return None + + +async def assign_phases(): + """Main function to assign phases to activities""" + db = get_db() + + # Find all activities with session but no work phase + logger.info("Finding activities that need phase assignment...") + + with db.get_connection() as conn: + cursor = conn.execute( + """ + SELECT id, title, start_time, pomodoro_session_id + FROM activities + WHERE pomodoro_session_id IS NOT NULL + AND pomodoro_work_phase IS NULL + AND deleted = 0 + ORDER BY start_time + """ + ) + activities = cursor.fetchall() + + if not activities: + logger.info("✓ No activities need phase assignment") + return + + logger.info(f"Found {len(activities)} activities needing phase assignment") + + updated_count = 0 + failed_count = 0 + + for activity_row in activities: + activity_id = activity_row[0] + title = activity_row[1] + start_time = activity_row[2] + session_id = activity_row[3] + + try: + # Get session + session = await db.pomodoro_sessions.get_by_id(session_id) + if not session: + logger.warning( + f"Session {session_id} not found for activity {activity_id}" + ) + failed_count += 1 + continue + + # Calculate phase timeline + phase_timeline = calculate_phase_timeline(session) + + if not phase_timeline: + logger.warning( + f"No phases calculated for session {session_id} " + f"(completed_rounds: {session.get('completed_rounds', 0)})" + ) + failed_count += 1 + continue + + # Determine work phase + work_phase = determine_work_phase(start_time, phase_timeline) + + if work_phase is None: + logger.warning( + f"Could not determine phase for activity {activity_id} " + f"(start_time: {start_time})" + ) + failed_count += 1 + continue + + # Update activity with work phase + conn.execute( + """ + UPDATE activities + SET pomodoro_work_phase = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (work_phase, activity_id), + ) + + logger.info( + f"✓ Assigned phase {work_phase} to activity: {title} " + f"(session: {session.get('user_intent', 'Unknown')[:30]}...)" + ) + updated_count += 1 + + except Exception as e: + logger.error( + f"Failed to process activity {activity_id}: {e}", exc_info=True + ) + failed_count += 1 + + conn.commit() + + logger.info("=" * 60) + logger.info(f"Phase assignment completed:") + logger.info(f" ✓ Updated: {updated_count}") + logger.info(f" ✗ Failed: {failed_count}") + logger.info(f" Total processed: {len(activities)}") + logger.info("=" * 60) + + +def main(): + """Entry point""" + logger.info("Starting activity phase assignment...") + asyncio.run(assign_phases()) + logger.info("Done!") + + +if __name__ == "__main__": + main() diff --git a/scripts/check_i18n_usage.py b/scripts/check_i18n_usage.py new file mode 100644 index 0000000..21653c6 --- /dev/null +++ b/scripts/check_i18n_usage.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +""" +Check i18n key usage and identify unused keys. +This script: +1. Extracts all i18n keys from the locale files +2. Searches for usage of each key in the codebase +3. Reports which keys are unused +""" + +import json +import re +import subprocess +from pathlib import Path +from typing import Dict, List, Set + +# Project root directory +PROJECT_ROOT = Path(__file__).parent.parent + +# Locale files +EN_LOCALE = PROJECT_ROOT / "src/locales/en.ts" +ZH_CN_LOCALE = PROJECT_ROOT / "src/locales/zh-CN.ts" + +# Directories to search for usage +SEARCH_DIR = PROJECT_ROOT / "src" + + +def extract_keys_recursive(obj: dict, prefix: str = "") -> List[str]: + """ + Recursively extract all leaf keys from a nested dictionary. + """ + keys = [] + + for key, value in obj.items(): + full_key = f"{prefix}.{key}" if prefix else key + + if isinstance(value, dict): + # Recursively process nested objects + keys.extend(extract_keys_recursive(value, full_key)) + elif isinstance(value, list): + # Handle arrays - add the key itself + keys.append(full_key) + else: + # Leaf node - add the key + keys.append(full_key) + + return keys + + +def parse_typescript_object(file_path: Path) -> dict: + """ + Parse TypeScript object by converting to JSON-like format. + This is a simplified approach that works for our use case. + """ + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + # Remove type annotations and comments + content = re.sub(r"//.*", "", content) # Remove line comments + content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL) # Remove block comments + + # Extract the object literal + match = re.search(r"(?:export const \w+ = |= )(\{.*\})", content, re.DOTALL) + if not match: + print("ERROR: Could not parse file") + return {} + + obj_str = match.group(1) + + # Simple transformation to make it JSON-like + # Replace single quotes with double quotes + obj_str = re.sub(r"'([^']*)'", r'"\1"', obj_str) + + # Remove 'as const' and 'satisfies Translation' + obj_str = re.sub(r"\s*as\s+const\s*$", "", obj_str) + obj_str = re.sub(r"\s*satisfies\s+\w+\s*$", "", obj_str) + + # Handle template literals (keep them as strings) + obj_str = re.sub(r"`([^`]*)`", r'"\1"', obj_str) + + # Add quotes to unquoted keys + obj_str = re.sub(r'(\w+):', r'"\1":', obj_str) + + # Remove trailing commas + obj_str = re.sub(r",(\s*[}\]])", r"\1", obj_str) + + try: + return json.loads(obj_str) + except json.JSONDecodeError as e: + print(f"JSON parse error: {e}") + # Fallback: manual parsing + return parse_manually(file_path) + + +def parse_manually(file_path: Path) -> dict: + """ + Manual parsing as fallback. + Build the key tree by tracking nesting levels. + """ + with open(file_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + result = {} + stack = [result] + key_stack = [] + + in_export = False + for line in lines: + stripped = line.strip() + + # Skip empty lines and comments + if not stripped or stripped.startswith("//"): + continue + + # Start tracking after "export const en =" + if "export const" in line and "=" in line: + in_export = True + continue + + if not in_export: + continue + + # End of object + if stripped == "} as const" or stripped == "} as const satisfies Translation": + break + + # Match key: value or key: { + match = re.match(r"^(\w+):\s*(.*)$", stripped) + if not match: + # Check for closing braces + if stripped.startswith("}"): + if stack and len(stack) > 1: + stack.pop() + if key_stack: + key_stack.pop() + continue + + key = match.group(1) + rest = match.group(2).strip() + + # Determine if this is a nested object or a leaf value + if rest.startswith("{"): + # Nested object + new_dict = {} + stack[-1][key] = new_dict + stack.append(new_dict) + key_stack.append(key) + elif rest.startswith("["): + # Array value - treat as leaf + stack[-1][key] = [] + else: + # Leaf value (string, number, etc.) + # Extract value (remove trailing comma) + value = re.sub(r",$", "", rest) + stack[-1][key] = value + + return result + + +def extract_all_keys(file_path: Path) -> Set[str]: + """Extract all i18n keys from a locale file.""" + print(f"Extracting i18n keys from {file_path.name}...") + + # Try JSON-like parsing first + obj = parse_typescript_object(file_path) + + if not obj: + print("Falling back to manual parsing...") + obj = parse_manually(file_path) + + if not obj: + print("ERROR: Could not parse file") + return set() + + keys = extract_keys_recursive(obj) + print(f"Found {len(keys)} keys") + + return set(keys) + + +def search_key_usage(key: str) -> bool: + """ + Search for usage of a key in the codebase using ripgrep. + Returns True if the key is used, False otherwise. + """ + # Escape special regex characters in the key + escaped_key = re.escape(key) + + # Search for patterns like t('key') or t("key") + # Use word boundary to avoid partial matches + patterns = [ + f"t\\(['\\\"]({escaped_key})['\\\"]", + f"t\\(['\\\"]({escaped_key})\\.", # Dynamic keys like t('key.subkey') + ] + + for pattern in patterns: + try: + result = subprocess.run( + ["rg", "-q", pattern, str(SEARCH_DIR)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return True + except FileNotFoundError: + # ripgrep not available, fall back to simple text search + try: + # Just search for the key name literally + simple_pattern = f't("{key}")' + result = subprocess.run( + ["grep", "-r", "-q", simple_pattern, str(SEARCH_DIR)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return True + + simple_pattern2 = f"t('{key}')" + result = subprocess.run( + ["grep", "-r", "-q", simple_pattern2, str(SEARCH_DIR)], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return True + except Exception: + pass + + return False + + +def find_unused_keys(keys: Set[str]) -> Set[str]: + """Find keys that are not used in the codebase.""" + print("\nSearching for key usage in the codebase...") + print("This may take a while...\n") + + unused = set() + used = set() + + total = len(keys) + for idx, key in enumerate(sorted(keys), 1): + if idx % 50 == 0 or idx == total: + print(f"Progress: {idx}/{total} ({idx*100//total}%)") + + if not search_key_usage(key): + unused.add(key) + else: + used.add(key) + + print(f"\nUsed keys: {len(used)}") + print(f"Unused keys: {len(unused)}") + + return unused + + +def main(): + print("=" * 60) + print("i18n Key Usage Checker") + print("=" * 60) + + # Extract all keys + all_keys = extract_all_keys(EN_LOCALE) + + if not all_keys: + print("No keys found!") + return + + # Sample a few keys to verify parsing + sample_keys = sorted(all_keys)[:10] + print("\nSample keys (first 10):") + for key in sample_keys: + print(f" - {key}") + + # Find unused keys + unused_keys = find_unused_keys(all_keys) + + # Report results + print("\n" + "=" * 60) + print("RESULTS") + print("=" * 60) + + if unused_keys: + print(f"\nFound {len(unused_keys)} unused keys:\n") + for key in sorted(unused_keys): + print(f" - {key}") + + # Save to file for review + output_file = PROJECT_ROOT / "unused_i18n_keys.txt" + with open(output_file, "w") as f: + f.write("Unused i18n keys:\n") + f.write("=" * 60 + "\n\n") + for key in sorted(unused_keys): + f.write(f"{key}\n") + + print(f"\nResults saved to: {output_file}") + else: + print("\nNo unused keys found! All keys are being used.") + + print("\n" + "=" * 60) + + +if __name__ == "__main__": + main() diff --git a/scripts/generate-sounds.js b/scripts/generate-sounds.js new file mode 100644 index 0000000..b794664 --- /dev/null +++ b/scripts/generate-sounds.js @@ -0,0 +1,202 @@ +#!/usr/bin/env node + +/** + * Generate 8-bit/16-bit style notification sounds for Pomodoro phase transitions + * Creates simple WAV files with retro chiptune aesthetic + */ + +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// WAV file header structure +function createWavHeader(dataLength, sampleRate, numChannels, bitsPerSample) { + const byteRate = (sampleRate * numChannels * bitsPerSample) / 8 + const blockAlign = (numChannels * bitsPerSample) / 8 + const buffer = Buffer.alloc(44) + + // "RIFF" chunk descriptor + buffer.write('RIFF', 0) + buffer.writeUInt32LE(36 + dataLength, 4) // File size - 8 + buffer.write('WAVE', 8) + + // "fmt " sub-chunk + buffer.write('fmt ', 12) + buffer.writeUInt32LE(16, 16) // Subchunk1Size (16 for PCM) + buffer.writeUInt16LE(1, 20) // AudioFormat (1 for PCM) + buffer.writeUInt16LE(numChannels, 22) + buffer.writeUInt32LE(sampleRate, 24) + buffer.writeUInt32LE(byteRate, 28) + buffer.writeUInt16LE(blockAlign, 32) + buffer.writeUInt16LE(bitsPerSample, 34) + + // "data" sub-chunk + buffer.write('data', 36) + buffer.writeUInt32LE(dataLength, 40) + + return buffer +} + +// Generate 8-bit square wave (retro game sound) +function generate8BitChime(frequency, duration, sampleRate = 22050) { + const numSamples = Math.floor(duration * sampleRate) + const samples = Buffer.alloc(numSamples) + + for (let i = 0; i < numSamples; i++) { + const t = i / sampleRate + // Square wave with envelope + const envelope = Math.exp(-t * 3) // Fast decay + const wave = Math.sin(2 * Math.PI * frequency * t) > 0 ? 1 : -1 + const sample = wave * envelope * 127 + samples.writeInt8(Math.floor(sample), i) + } + + return samples +} + +// Generate 16-bit bell-like sound +function generate16BitBell(frequency, duration, sampleRate = 44100) { + const numSamples = Math.floor(duration * sampleRate) + const samples = Buffer.alloc(numSamples * 2) // 16-bit = 2 bytes per sample + + for (let i = 0; i < numSamples; i++) { + const t = i / sampleRate + // Harmonic bell sound with overtones + const envelope = Math.exp(-t * 4) + const fundamental = Math.sin(2 * Math.PI * frequency * t) + const harmonic1 = 0.5 * Math.sin(2 * Math.PI * frequency * 2 * t) + const harmonic2 = 0.25 * Math.sin(2 * Math.PI * frequency * 3 * t) + const wave = fundamental + harmonic1 + harmonic2 + const sample = wave * envelope * 16384 // 16-bit range + samples.writeInt16LE(Math.floor(sample), i * 2) + } + + return samples +} + +// Generate ascending 8-bit arpeggio (victory/completion sound) +function generate8BitMelody(sampleRate = 22050) { + const duration = 1.2 + const numSamples = Math.floor(duration * sampleRate) + const samples = Buffer.alloc(numSamples) + + // Arpeggio notes: C5, E5, G5, C6 (major chord) + const notes = [523.25, 659.25, 783.99, 1046.5] + const noteDuration = duration / notes.length + + for (let i = 0; i < numSamples; i++) { + const t = i / sampleRate + const noteIndex = Math.floor(t / noteDuration) + const frequency = notes[Math.min(noteIndex, notes.length - 1)] + const noteTime = t - noteIndex * noteDuration + + // Square wave with note-specific envelope + const envelope = Math.exp(-noteTime * 5) + const wave = Math.sin(2 * Math.PI * frequency * noteTime) > 0 ? 1 : -1 + const sample = wave * envelope * 127 + samples.writeInt8(Math.floor(sample), i) + } + + return samples +} + +// Write WAV file +function writeWavFile(filename, samples, sampleRate, bitsPerSample) { + const header = createWavHeader(samples.length, sampleRate, 1, bitsPerSample) + const wavData = Buffer.concat([header, samples]) + fs.writeFileSync(filename, wavData) + console.log(`✓ Generated ${filename} (${Math.round(wavData.length / 1024)}KB)`) +} + +// Generate 16-bit bell with two notes (ding-dong pattern) +function generate16BitDingDong(freq1, freq2, sampleRate = 44100) { + const noteDuration = 0.35 // Each note duration + const totalDuration = noteDuration * 2 + 0.1 // Two notes with gap + const numSamples = Math.floor(totalDuration * sampleRate) + const samples = Buffer.alloc(numSamples * 2) // 16-bit = 2 bytes per sample + + for (let i = 0; i < numSamples; i++) { + const t = i / sampleRate + let wave = 0 + + // First note (ding) + if (t < noteDuration) { + const envelope = Math.exp(-t * 4.5) + const fundamental = Math.sin(2 * Math.PI * freq1 * t) + const harmonic1 = 0.5 * Math.sin(2 * Math.PI * freq1 * 2 * t) + const harmonic2 = 0.25 * Math.sin(2 * Math.PI * freq1 * 3 * t) + wave = (fundamental + harmonic1 + harmonic2) * envelope + } + // Second note (dong) + else if (t >= noteDuration + 0.05 && t < totalDuration) { + const t2 = t - (noteDuration + 0.05) + const envelope = Math.exp(-t2 * 4) + const fundamental = Math.sin(2 * Math.PI * freq2 * t2) + const harmonic1 = 0.5 * Math.sin(2 * Math.PI * freq2 * 2 * t2) + const harmonic2 = 0.25 * Math.sin(2 * Math.PI * freq2 * 3 * t2) + wave = (fundamental + harmonic1 + harmonic2) * envelope + } + + const sample = wave * 16384 // 16-bit range + samples.writeInt16LE(Math.floor(sample), i * 2) + } + + return samples +} + +// Generate 16-bit bell with ascending arpeggio (C-E-G chord) +function generate16BitArpeggio(sampleRate = 44100) { + const notes = [523.25, 659.25, 783.99] // C5, E5, G5 (major chord) + const noteDuration = 0.3 + const totalDuration = noteDuration * notes.length + 0.2 + const numSamples = Math.floor(totalDuration * sampleRate) + const samples = Buffer.alloc(numSamples * 2) // 16-bit + + for (let i = 0; i < numSamples; i++) { + const t = i / sampleRate + const noteIndex = Math.floor(t / noteDuration) + const frequency = notes[Math.min(noteIndex, notes.length - 1)] + const noteTime = t - noteIndex * noteDuration + + // Only play during note duration, not in gaps + let wave = 0 + if (noteIndex < notes.length && noteTime < noteDuration - 0.05) { + const envelope = Math.exp(-noteTime * 5) + const fundamental = Math.sin(2 * Math.PI * frequency * noteTime) + const harmonic1 = 0.5 * Math.sin(2 * Math.PI * frequency * 2 * noteTime) + const harmonic2 = 0.25 * Math.sin(2 * Math.PI * frequency * 3 * noteTime) + wave = (fundamental + harmonic1 + harmonic2) * envelope + } + + const sample = wave * 16384 // 16-bit range + samples.writeInt16LE(Math.floor(sample), i * 2) + } + + return samples +} + +// Main execution +const outputDir = path.join(__dirname, '../src/assets/sounds') + +console.log('Generating notification sounds based on 16-bit bell tone...\n') + +// 1. Work phase complete - 16-bit ding-dong (E5 -> C5, cheerful descending) +const workComplete = generate16BitDingDong(659.25, 523.25, 44100) // E5 -> C5 +writeWavFile(path.join(outputDir, 'work-complete.wav'), workComplete, 44100, 16) + +// 2. Break phase complete - 16-bit single bell (C5, gentle, calming) +const breakComplete = generate16BitBell(523.25, 0.8, 44100) // C5 note +writeWavFile(path.join(outputDir, 'break-complete.wav'), breakComplete, 44100, 16) + +// 3. Session complete - 16-bit ascending arpeggio (C5-E5-G5, celebratory) +const sessionComplete = generate16BitArpeggio(44100) +writeWavFile(path.join(outputDir, 'session-complete.wav'), sessionComplete, 44100, 16) + +console.log('\n✅ All notification sounds generated successfully!') +console.log('Sound design:') +console.log(' - Work Complete: E5→C5 ding-dong (descending, satisfying completion)') +console.log(' - Break Complete: C5 single bell (gentle reminder)') +console.log(' - Session Complete: C5-E5-G5 arpeggio (ascending, celebratory)') diff --git a/scripts/remove_unused_i18n_keys.py b/scripts/remove_unused_i18n_keys.py new file mode 100644 index 0000000..7695dc0 --- /dev/null +++ b/scripts/remove_unused_i18n_keys.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +""" +Remove unused i18n keys from locale files. +Reads the unused keys from unused_i18n_keys.txt and removes them from both locale files. +""" + +import re +from pathlib import Path +from typing import Set, List, Dict + +# Project root directory +PROJECT_ROOT = Path(__file__).parent.parent + +# Locale files +EN_LOCALE = PROJECT_ROOT / "src/locales/en.ts" +ZH_CN_LOCALE = PROJECT_ROOT / "src/locales/zh-CN.ts" + +# Unused keys file +UNUSED_KEYS_FILE = PROJECT_ROOT / "unused_i18n_keys.txt" + + +def load_unused_keys() -> Set[str]: + """Load the list of unused keys from the file.""" + print(f"Loading unused keys from {UNUSED_KEYS_FILE.name}...") + + unused_keys = set() + + with open(UNUSED_KEYS_FILE, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line and not line.startswith("=") and line != "Unused i18n keys:": + unused_keys.add(line) + + print(f"Loaded {len(unused_keys)} unused keys") + return unused_keys + + +def should_remove_key(full_path: str, unused_keys: Set[str]) -> bool: + """ + Check if a key should be removed. + A key should be removed if it's in the unused list AND it's a leaf node + (i.e., not a parent of other used keys). + """ + return full_path in unused_keys + + +def remove_keys_from_file(file_path: Path, unused_keys: Set[str]) -> None: + """ + Remove unused keys from a locale file. + This function: + 1. Reads the file line by line + 2. Tracks the current key path + 3. Skips lines that belong to unused keys + 4. Writes the cleaned content back to the file + """ + print(f"\nProcessing {file_path.name}...") + + with open(file_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + new_lines = [] + skip_until_depth = None + current_path = [] + depth = 0 + removed_count = 0 + + for i, line in enumerate(lines): + stripped = line.strip() + + # Always keep header and footer + if ( + not stripped + or stripped.startswith("//") + or stripped.startswith("/*") + or "import" in line + or "export const" in line + or "export type" in line + or "type DeepStringify" in line + or stripped.startswith("?") + or stripped.startswith(":") + or stripped.startswith("}") + and i >= len(lines) - 5 + ): + new_lines.append(line) + continue + + # Count braces to track depth + line_open_braces = line.count("{") - line.count("'{'") - line.count('"{') + line_close_braces = line.count("}") - line.count("'}'") - line.count('"}' ) + + # Check if we're currently skipping content + if skip_until_depth is not None: + # Check if we've returned to the skip depth (closing brace) + if stripped.startswith("}") and depth <= skip_until_depth: + skip_until_depth = None + depth -= line_close_braces + if current_path: + current_path.pop() + else: + depth += line_open_braces - line_close_braces + continue + + # Try to match a key definition + key_match = re.match(r"^(\w+):\s*(.*)$", stripped) + + if key_match: + key_name = key_match.group(1) + rest = key_match.group(2).strip() + + # Build full path + full_path = ".".join(current_path + [key_name]) + + # Check if this key should be removed + if should_remove_key(full_path, unused_keys): + print(f" Removing: {full_path}") + removed_count += 1 + + # If this is a nested object, skip until we close it + if rest.startswith("{"): + skip_until_depth = depth + current_path.append(key_name) + depth += line_open_braces - line_close_braces + continue + + # This key is kept - check if it's a nested object + if rest.startswith("{"): + current_path.append(key_name) + + # Handle closing braces + if stripped.startswith("}"): + if current_path: + current_path.pop() + + # Update depth + depth += line_open_braces - line_close_braces + + # Keep this line + new_lines.append(line) + + # Write back to file + with open(file_path, "w", encoding="utf-8") as f: + f.writelines(new_lines) + + print(f" Removed {removed_count} keys from {file_path.name}") + + +def clean_empty_objects(file_path: Path) -> None: + """ + Clean up empty objects left after removing keys. + For example, if we remove all keys from an object, remove the empty object too. + """ + print(f"\nCleaning empty objects in {file_path.name}...") + + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + + # Remove empty objects (key: {}) + # This regex matches: key: {\n } + content = re.sub(r"(\w+):\s*\{\s*\},?\n", "", content) + content = re.sub(r"(\w+):\s*\{\s*\}\n", "", content) + + # Remove trailing commas before closing braces + content = re.sub(r",(\s*\})", r"\1", content) + + # Remove multiple consecutive blank lines + content = re.sub(r"\n\n\n+", "\n\n", content) + + with open(file_path, "w", encoding="utf-8") as f: + f.write(content) + + print(f" Cleaned empty objects in {file_path.name}") + + +def main(): + print("=" * 60) + print("i18n Unused Keys Removal Tool") + print("=" * 60) + + # Load unused keys + unused_keys = load_unused_keys() + + if not unused_keys: + print("\nNo unused keys to remove!") + return + + # Create backups + print("\nCreating backups...") + import shutil + + shutil.copy2(EN_LOCALE, str(EN_LOCALE) + ".backup") + shutil.copy2(ZH_CN_LOCALE, str(ZH_CN_LOCALE) + ".backup") + print(" Backups created (.backup files)") + + # Remove keys from both files + remove_keys_from_file(EN_LOCALE, unused_keys) + remove_keys_from_file(ZH_CN_LOCALE, unused_keys) + + # Clean up empty objects + clean_empty_objects(EN_LOCALE) + clean_empty_objects(ZH_CN_LOCALE) + + print("\n" + "=" * 60) + print("COMPLETED") + print("=" * 60) + print("\nUnused keys have been removed from both locale files.") + print("Backup files have been created (.backup extension).") + print("\nNext steps:") + print("1. Run `pnpm check-i18n` to verify the changes") + print("2. Test the application to ensure nothing is broken") + print("3. If everything works, delete the .backup files") + print("4. If there are issues, restore from .backup files") + print("\n" + "=" * 60) + + +if __name__ == "__main__": + main() diff --git a/scripts/remove_unused_i18n_keys_v2.py b/scripts/remove_unused_i18n_keys_v2.py new file mode 100644 index 0000000..2bff8b9 --- /dev/null +++ b/scripts/remove_unused_i18n_keys_v2.py @@ -0,0 +1,307 @@ +#!/usr/bin/env python3 +""" +Remove unused i18n keys from locale files (improved version). +This version handles multi-line string values correctly. +""" + +import re +from pathlib import Path +from typing import Set, List + +# Project root directory +PROJECT_ROOT = Path(__file__).parent.parent + +# Locale files +EN_LOCALE = PROJECT_ROOT / "src/locales/en.ts" +ZH_CN_LOCALE = PROJECT_ROOT / "src/locales/zh-CN.ts" + +# Unused keys file +UNUSED_KEYS_FILE = PROJECT_ROOT / "unused_i18n_keys.txt" + + +def load_unused_keys() -> Set[str]: + """Load the list of unused keys from the file.""" + print(f"Loading unused keys from {UNUSED_KEYS_FILE.name}...") + + unused_keys = set() + + with open(UNUSED_KEYS_FILE, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if line and not line.startswith("=") and line != "Unused i18n keys:": + unused_keys.add(line) + + print(f"Loaded {len(unused_keys)} unused keys") + return unused_keys + + +def remove_keys_from_content(content: str, unused_keys: Set[str]) -> tuple[str, int]: + """ + Remove unused keys from file content. + Returns (new_content, removed_count) + """ + # Group unused keys by their parent path for efficient removal + keys_by_depth = {} + for key in unused_keys: + parts = key.split(".") + depth = len(parts) + if depth not in keys_by_depth: + keys_by_depth[depth] = set() + keys_by_depth[depth].add(key) + + removed_count = 0 + + # Remove keys from deepest to shallowest to avoid issues + for depth in sorted(keys_by_depth.keys(), reverse=True): + for key in keys_by_depth[depth]: + # Build regex pattern to match the key and its value + parts = key.split(".") + key_name = parts[-1] + + # Pattern to match: + # - key name + # - optional whitespace + # - colon + # - value (can be string, object, or array) + # - optional comma + # Handles multi-line strings and objects + + # For leaf keys (strings): + # keyName: 'value', + # or + # keyName: + # 'multi-line value', + pattern_simple = rf"^\s*{re.escape(key_name)}:\s*['\"`].*?['\"`],?\s*$" + + # For nested objects: + # keyName: {{ ... }}, + # We need to match balanced braces + + # Try simple pattern first (single-line or multi-line string) + lines = content.split("\n") + new_lines = [] + i = 0 + while i < len(lines): + line = lines[i] + stripped = line.strip() + + # Check if this line starts with our key + if re.match(rf"^\s*{re.escape(key_name)}:\s*", line): + # Check the current path context to ensure we're removing the right key + # For now, we'll use a simpler approach: just match the key name + # and check if it's a simple value or nested object + + rest_of_line = line.split(":", 1)[1].strip() + + if rest_of_line.startswith("{"): + # Nested object - skip until closing brace + brace_count = rest_of_line.count("{") - rest_of_line.count("}") + i += 1 + while i < len(lines) and brace_count > 0: + brace_count += ( + lines[i].count("{") - lines[i].count("}") + ) + i += 1 + removed_count += 1 + continue + elif rest_of_line.startswith("["): + # Array - skip until closing bracket + bracket_count = rest_of_line.count("[") - rest_of_line.count( + "]" + ) + i += 1 + while i < len(lines) and bracket_count > 0: + bracket_count += ( + lines[i].count("[") - lines[i].count("]") + ) + i += 1 + removed_count += 1 + continue + else: + # Simple value - might be multi-line + # Skip this line and check if next line continues the value + if not rest_of_line.endswith(",") and not rest_of_line.endswith("'") and not rest_of_line.endswith('"'): + # Multi-line string, check next line + i += 1 + if i < len(lines) and lines[i].strip().startswith("'"): + i += 1 # Skip the value line too + removed_count += 1 + i += 1 + continue + + new_lines.append(line) + i += 1 + + content = "\n".join(new_lines) + + return content, removed_count + + +def clean_empty_objects(content: str) -> str: + """Clean up empty objects and formatting.""" + # Remove empty objects + content = re.sub(r"\w+:\s*\{\s*\},?\n", "", content) + + # Remove trailing commas before closing braces + content = re.sub(r",(\s*\})", r"\1", content) + + # Remove multiple consecutive blank lines + content = re.sub(r"\n\n\n+", "\n\n", content) + + # Fix spacing issues + lines = content.split("\n") + fixed_lines = [] + for i, line in enumerate(lines): + # Skip empty lines at the start of an object + if i > 0 and line.strip() == "" and lines[i - 1].strip().endswith("{"): + continue + fixed_lines.append(line) + + return "\n".join(fixed_lines) + + +def remove_keys_manually(file_path: Path, unused_keys: Set[str]) -> int: + """ + Use Edit tool approach - build map of which lines to keep. + This is more reliable for complex TypeScript objects. + """ + print(f"\nProcessing {file_path.name} with manual approach...") + + with open(file_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + # Build a context path as we scan through the file + current_path = [] + lines_to_keep = [] + skip_until_line = -1 + removed_count = 0 + + for line_num, line in enumerate(lines): + # Skip if we're in a block we're removing + if line_num <= skip_until_line: + continue + + stripped = line.strip() + + # Keep structural lines + if ( + not stripped + or stripped.startswith("//") + or stripped.startswith("/*") + or "import " in line + or "export " in line + or "type " in line + or stripped.startswith("?") + or stripped.startswith(":") + ): + lines_to_keep.append(line) + continue + + # Try to extract key name + key_match = re.match(r"^(\s*)(\w+):\s*(.*)$", line) + + if key_match: + indent = key_match.group(1) + key_name = key_match.group(2) + rest = key_match.group(3).strip() + + # Update current path based on indentation + indent_level = len(indent) // 2 + current_path = current_path[:indent_level] + full_path = ".".join(current_path + [key_name]) + + # Check if this key should be removed + if full_path in unused_keys: + print(f" Removing: {full_path}") + removed_count += 1 + + # Skip this key and its value + if rest.startswith("{"): + # Find the closing brace + brace_count = 1 + for j in range(line_num + 1, len(lines)): + brace_count += lines[j].count("{") - lines[j].count("}") + if brace_count == 0: + skip_until_line = j + break + else: + # Simple value - skip just this line + # Check if next line is a continuation + if line_num + 1 < len(lines): + next_line = lines[line_num + 1].strip() + if next_line and not next_line.startswith("}") and not re.match(r"^\w+:", next_line): + skip_until_line = line_num + 1 + continue + + # This key is kept + if rest.startswith("{"): + current_path.append(key_name) + + # Handle closing braces + if stripped.startswith("}"): + if current_path: + current_path.pop() + + lines_to_keep.append(line) + + # Write back + with open(file_path, "w", encoding="utf-8") as f: + f.writelines(lines_to_keep) + + return removed_count + + +def main(): + print("=" * 60) + print("i18n Unused Keys Removal Tool (v2)") + print("=" * 60) + + # Load unused keys + unused_keys = load_unused_keys() + + if not unused_keys: + print("\nNo unused keys to remove!") + return + + # Create backups (if not already exist) + print("\nChecking backups...") + import shutil + + if not EN_LOCALE.with_suffix(".ts.backup").exists(): + shutil.copy2(EN_LOCALE, str(EN_LOCALE) + ".backup") + shutil.copy2(ZH_CN_LOCALE, str(ZH_CN_LOCALE) + ".backup") + print(" Backups created (.backup files)") + else: + print(" Using existing backups") + + # Remove keys from both files + en_removed = remove_keys_manually(EN_LOCALE, unused_keys) + print(f" Removed {en_removed} keys from en.ts") + + zh_removed = remove_keys_manually(ZH_CN_LOCALE, unused_keys) + print(f" Removed {zh_removed} keys from zh-CN.ts") + + # Clean up formatting + print("\nCleaning up formatting...") + for file_path in [EN_LOCALE, ZH_CN_LOCALE]: + with open(file_path, "r", encoding="utf-8") as f: + content = f.read() + content = clean_empty_objects(content) + with open(file_path, "w", encoding="utf-8") as f: + f.write(content) + + print("\n" + "=" * 60) + print("COMPLETED") + print("=" * 60) + print("\nUnused keys have been removed from both locale files.") + print("Backup files are available (.backup extension).") + print("\nNext steps:") + print("1. Run `pnpm check-i18n` to verify the changes") + print("2. Test the application to ensure nothing is broken") + print("3. If everything works, delete the .backup files") + print("4. If there are issues, restore from .backup files") + print("\n" + "=" * 60) + + +if __name__ == "__main__": + main() diff --git a/src-tauri/python/ido_app/__init__.py b/src-tauri/python/ido_app/__init__.py index f379c97..3d98a93 100644 --- a/src-tauri/python/ido_app/__init__.py +++ b/src-tauri/python/ido_app/__init__.py @@ -134,7 +134,8 @@ def log_main(msg: str) -> None: ) # ⭐ The CLI to run `json-schema-to-typescript`, # `--format=false` is optional to improve performance - json2ts_cmd = "pnpm json2ts --format=false" + # `--unknownAny=false` uses 'any' instead of 'unknown' for better compatibility + json2ts_cmd = "pnpm json2ts --format=false --unknownAny=false" # ⭐ Start the background task to generate TypeScript types portal.start_task_soon( diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index eb02705..b7969c5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -8,7 +8,9 @@ "devUrl": "http://127.0.0.1:1420/", "beforeBuildCommand": "pnpm run build", "frontendDist": "../dist", - "features": ["pytauri/standalone"] + "features": [ + "pytauri/standalone" + ] }, "app": { "security": { @@ -16,7 +18,9 @@ "assetProtocol": { "enable": true, "scope": { - "allow": ["$HOME/.config/ido/**"], + "allow": [ + "$HOME/.config/ido/**" + ], "deny": [] } } @@ -35,7 +39,9 @@ }, "plugins": { "sql": { - "preload": ["sqlite:ido.db"] + "preload": [ + "sqlite:ido.db" + ] }, "process": { "allow-exit": true diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index de2ef28..76fcccf 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -8,7 +8,9 @@ "devUrl": "http://localhost:1420", "beforeBuildCommand": "pnpm run build", "frontendDist": "../dist", - "features": ["pytauri/standalone"] + "features": [ + "pytauri/standalone" + ] }, "app": { "withGlobalTauri": true, @@ -20,7 +22,7 @@ "width": 1300, "height": 1000, "minWidth": 1020, - "minHeight": 600, + "minHeight": 840, "fullscreen": false, "resizable": true, "center": true, @@ -35,8 +37,16 @@ }, "bundle": { "active": true, - "targets": ["app"], - "icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.icns", "icons/icon.ico"], + "targets": [ + "app" + ], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ], "macOS": { "entitlements": "entitlements.plist", "minimumSystemVersion": "10.15", diff --git a/src/assets/sounds/break-complete.wav b/src/assets/sounds/break-complete.wav new file mode 100644 index 0000000..bad86e2 Binary files /dev/null and b/src/assets/sounds/break-complete.wav differ diff --git a/src/assets/sounds/session-complete.wav b/src/assets/sounds/session-complete.wav new file mode 100644 index 0000000..2bb19e8 Binary files /dev/null and b/src/assets/sounds/session-complete.wav differ diff --git a/src/assets/sounds/work-complete.wav b/src/assets/sounds/work-complete.wav new file mode 100644 index 0000000..9597ef2 Binary files /dev/null and b/src/assets/sounds/work-complete.wav differ diff --git a/src/clock/App.css b/src/clock/App.css new file mode 100644 index 0000000..1040908 --- /dev/null +++ b/src/clock/App.css @@ -0,0 +1,78 @@ +/* Clock app styles */ + +.clock-container { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: rgba(17, 24, 39, 0.9); + border-radius: 8px; + position: relative; + /* Enable window dragging for the entire container */ + -webkit-app-region: drag; + -webkit-user-select: none; + user-select: none; + cursor: move; +} + +.progress-ring { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) rotate(-90deg); + /* Scale to fit container while maintaining aspect ratio */ + max-width: 90%; + max-height: 90%; +} + +.progress-ring-circle { + opacity: 0.3; +} + +.progress-ring-progress { + filter: drop-shadow(0 0 6px currentColor); +} + +.clock-content { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + color: white; +} + +.phase-label { + font-size: 14px; + font-weight: 600; + letter-spacing: 2px; + margin-bottom: 4px; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); +} + +.time-display { + font-size: 48px; + font-weight: 700; + font-variant-numeric: tabular-nums; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7); + line-height: 1; +} + +.round-info { + font-size: 12px; + color: #9ca3af; + margin-top: 8px; +} + +.user-intent { + font-size: 11px; + color: #6b7280; + max-width: 160px; + margin-top: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/clock/App.tsx b/src/clock/App.tsx new file mode 100644 index 0000000..a478c29 --- /dev/null +++ b/src/clock/App.tsx @@ -0,0 +1,204 @@ +/** + * Clock window app - Desktop countdown timer + */ + +import { useEffect, useState } from 'react' +import { listen } from '@tauri-apps/api/event' +import './App.css' + +interface ClockState { + sessionId: string | null + phase: 'work' | 'break' | 'completed' | null + remainingSeconds: number + totalSeconds: number + currentRound: number + totalRounds: number + completedRounds: number + userIntent: string + phaseStartTime: string | null + workDurationMinutes: number + breakDurationMinutes: number +} + +function App() { + const [state, setState] = useState({ + sessionId: null, + phase: null, + remainingSeconds: 0, + totalSeconds: 0, + currentRound: 1, + totalRounds: 4, + completedRounds: 0, + userIntent: '', + phaseStartTime: null, + workDurationMinutes: 25, + breakDurationMinutes: 5 + }) + + const [displayTime, setDisplayTime] = useState({ + minutes: 0, + seconds: 0 + }) + + const [currentClockTime, setCurrentClockTime] = useState({ + hours: 0, + minutes: 0 + }) + + const [currentTime, setCurrentTime] = useState(Date.now()) + + useEffect(() => { + const unlisten = listen('clock-update', (event) => { + console.log('[Clock App] State update:', event.payload) + const newState = event.payload + + // Update state (displayTime will be calculated from phaseStartTime) + setState(newState) + }) + + return () => { + unlisten.then((fn) => fn()) + } + }, []) + + // Show current time when no Pomodoro session is active + useEffect(() => { + if (!state.sessionId || !state.phase) { + const updateClock = () => { + const now = new Date() + setCurrentClockTime({ + hours: now.getHours(), + minutes: now.getMinutes() + }) + } + + updateClock() + const interval = setInterval(updateClock, 1000) + return () => clearInterval(interval) + } + }, [state.sessionId, state.phase]) + + // Update current time every second for real-time calculation (like main app) + useEffect(() => { + const interval = setInterval(() => { + setCurrentTime(Date.now()) + }, 1000) + + return () => clearInterval(interval) + }, []) + + // Calculate display time based on phaseStartTime (same as main app) + useEffect(() => { + if (state.phase === 'completed' || !state.phaseStartTime || !state.phase) { + return + } + + const phaseStartTime = new Date(state.phaseStartTime).getTime() + const phaseDuration = state.phase === 'work' ? state.workDurationMinutes * 60 : state.breakDurationMinutes * 60 + + const elapsedSeconds = Math.floor((currentTime - phaseStartTime) / 1000) + const remainingSeconds = Math.max(0, phaseDuration - elapsedSeconds) + + setDisplayTime({ + minutes: Math.floor(remainingSeconds / 60), + seconds: remainingSeconds % 60 + }) + }, [currentTime, state.phaseStartTime, state.phase, state.workDurationMinutes, state.breakDurationMinutes]) + + const getPhaseColor = () => { + switch (state.phase) { + case 'work': + return '#ef4444' // red + case 'break': + return '#22c55e' // green + case 'completed': + return '#3b82f6' // blue + default: + return '#6b7280' // gray + } + } + + const getPhaseLabel = () => { + if (!state.phase) { + return 'CLOCK' + } + switch (state.phase) { + case 'work': + return 'WORK' + case 'break': + return 'BREAK' + case 'completed': + return 'DONE' + default: + return '' + } + } + + // Calculate progress based on phaseStartTime (same as main app) + const progress = (() => { + if (!state.phaseStartTime || !state.phase || state.phase === 'completed') { + return 0 + } + + const phaseStartTime = new Date(state.phaseStartTime).getTime() + const phaseDuration = state.phase === 'work' ? state.workDurationMinutes * 60 : state.breakDurationMinutes * 60 + + const elapsedSeconds = Math.floor((currentTime - phaseStartTime) / 1000) + return Math.min(100, (elapsedSeconds / phaseDuration) * 100) + })() + + const circumference = 2 * Math.PI * 90 + const strokeDashoffset = circumference - (progress / 100) * circumference + + return ( +
+ + + + + +
+
+ {getPhaseLabel()} +
+ +
+ {state.phase + ? `${String(displayTime.minutes).padStart(2, '0')}:${String(displayTime.seconds).padStart(2, '0')}` + : `${String(currentClockTime.hours).padStart(2, '0')}:${String(currentClockTime.minutes).padStart(2, '0')}`} +
+ + {state.phase && ( +
+ Round {state.currentRound}/{state.totalRounds} +
+ )} + + {state.userIntent &&
{state.userIntent}
} +
+
+ ) +} + +export default App diff --git a/src/clock/index.tsx b/src/clock/index.tsx new file mode 100644 index 0000000..c381bd8 --- /dev/null +++ b/src/clock/index.tsx @@ -0,0 +1,13 @@ +/** + * Clock window main entry point + */ + +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('clock-root')!).render( + + + +) diff --git a/src/components/activity/ActionCard.tsx b/src/components/activity/ActionCard.tsx index 564a821..09c93cb 100644 --- a/src/components/activity/ActionCard.tsx +++ b/src/components/activity/ActionCard.tsx @@ -90,7 +90,7 @@ export function ActionCard({ action, isExpanded = false, onToggleExpand }: Actio {/* Title - takes up remaining space and wraps */}
-
{action.title}
+
{action.title}
{/* Timestamp and Screenshots button - takes up actual space */} @@ -164,8 +164,14 @@ export function ActionCard({ action, isExpanded = false, onToggleExpand }: Actio
) : ( -
+
+ Image Lost + {import.meta.env.DEV && ( + + {screenshot.substring(0, 8)}... + + )}
)}
diff --git a/src/components/activity/ActivityItem.tsx b/src/components/activity/ActivityItem.tsx index 944e838..6c31f32 100644 --- a/src/components/activity/ActivityItem.tsx +++ b/src/components/activity/ActivityItem.tsx @@ -2,7 +2,18 @@ import { Activity } from '@/lib/types/activity' import { useActivityStore } from '@/lib/stores/activity' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' -import { Clock, Loader2, MessageSquare, Sparkles, Trash2, Timer, Layers, ChevronDown, ChevronUp } from 'lucide-react' +import { + Clock, + Loader2, + MessageSquare, + Sparkles, + Trash2, + Timer, + Layers, + ChevronDown, + ChevronUp, + Target +} from 'lucide-react' import { EventCard } from './EventCard' import { ActionCard } from './ActionCard' import { cn, formatDuration } from '@/lib/utils' @@ -31,6 +42,48 @@ interface ActivityItemProps { onToggleSelection?: (activityId: string) => void } +// Helper function to get focus score display info +function getFocusScoreInfo(focusScore: number | undefined) { + if (focusScore === undefined || focusScore === null) { + return null + } + + // Define score levels and their styling + if (focusScore >= 80) { + return { + level: 'excellent', + label: 'Excellent Focus', + variant: 'default' as const, + bgClass: 'bg-green-500/10 border-green-500/20', + textClass: 'text-green-700 dark:text-green-400' + } + } else if (focusScore >= 60) { + return { + level: 'good', + label: 'Good Focus', + variant: 'secondary' as const, + bgClass: 'bg-blue-500/10 border-blue-500/20', + textClass: 'text-blue-700 dark:text-blue-400' + } + } else if (focusScore >= 40) { + return { + level: 'moderate', + label: 'Moderate Focus', + variant: 'outline' as const, + bgClass: 'bg-yellow-500/10 border-yellow-500/20', + textClass: 'text-yellow-700 dark:text-yellow-400' + } + } else { + return { + level: 'low', + label: 'Low Focus', + variant: 'destructive' as const, + bgClass: 'bg-red-500/10 border-red-500/20', + textClass: 'text-red-700 dark:text-red-400' + } + } +} + export function ActivityItem({ activity, selectionMode = false, @@ -77,11 +130,10 @@ export function ActivityItem({ return formatDuration(duration, 'short') }, [duration]) - // Determine if this is a milestone (long activity > 30 minutes) - const isMilestone = useMemo(() => { - const durationMinutes = duration / (1000 * 60) - return durationMinutes > 30 - }, [duration]) + // Get focus score display info + const focusScoreInfo = useMemo(() => { + return getFocusScoreInfo(activity.focusScore) + }, [activity.focusScore]) // Safely format time range with fallback for invalid timestamps let timeRange = '-- : -- : -- ~ -- : -- : --' @@ -260,10 +312,13 @@ export function ActivityItem({ {durationFormatted}
- {isMilestone && ( - - - {t('activity.milestone', 'Milestone')} + {focusScoreInfo && ( + + + {activity.focusScore?.toFixed(0)} )}
diff --git a/src/components/activity/SplitActivityDialog.tsx b/src/components/activity/SplitActivityDialog.tsx deleted file mode 100644 index 5be8ae6..0000000 --- a/src/components/activity/SplitActivityDialog.tsx +++ /dev/null @@ -1,317 +0,0 @@ -import { useState, useMemo } from 'react' -import { useTranslation } from 'react-i18next' -import { Activity } from '@/lib/types/activity' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Label } from '@/components/ui/label' -import { Checkbox } from '@/components/ui/checkbox' -import { Loader2, Split, Clock, Layers } from 'lucide-react' -import { format } from 'date-fns' -import { toast } from 'sonner' -import { splitActivityHandler } from '@/lib/client/apiClient' - -interface SplitActivityDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - activity: Activity | null - events: Array<{ id: string; title: string; startTime: number; endTime: number }> - onSplitSuccess?: () => void -} - -interface SplitGroup { - id: string - name: string - eventIds: Set -} - -export function SplitActivityDialog({ - open, - onOpenChange, - activity, - events, - onSplitSuccess -}: SplitActivityDialogProps) { - const { t } = useTranslation() - const [splitting, setSplitting] = useState(false) - - // Initialize two split groups - const [splitGroups, setSplitGroups] = useState([ - { id: 'group-1', name: 'Group 1', eventIds: new Set() }, - { id: 'group-2', name: 'Group 2', eventIds: new Set() } - ]) - - // Sort events by time - const sortedEvents = useMemo(() => { - return [...events].sort((a, b) => a.startTime - b.startTime) - }, [events]) - - const handleToggleEvent = (eventId: string, groupId: string) => { - setSplitGroups((prev) => - prev.map((group) => { - const newEventIds = new Set(group.eventIds) - if (group.id === groupId) { - // Toggle event in this group - if (newEventIds.has(eventId)) { - newEventIds.delete(eventId) - } else { - newEventIds.add(eventId) - } - } else { - // Remove from other groups - newEventIds.delete(eventId) - } - return { ...group, eventIds: newEventIds } - }) - ) - } - - const handleAddGroup = () => { - const newGroupId = `group-${splitGroups.length + 1}` - setSplitGroups((prev) => [ - ...prev, - { id: newGroupId, name: `Group ${splitGroups.length + 1}`, eventIds: new Set() } - ]) - } - - const handleRemoveGroup = (groupId: string) => { - if (splitGroups.length <= 2) { - toast.error(t('activity.mustHaveTwoGroups')) - return - } - setSplitGroups((prev) => prev.filter((g) => g.id !== groupId)) - } - - const handleSplit = async () => { - if (!activity) { - toast.error(t('activity.noActivitySelected')) - return - } - - // Validate: each group must have at least one event - const validGroups = splitGroups.filter((g) => g.eventIds.size > 0) - if (validGroups.length < 2) { - toast.error(t('activity.eachGroupNeedsEvents')) - return - } - - // Validate: all events must be assigned - const assignedEvents = new Set() - validGroups.forEach((g) => g.eventIds.forEach((id) => assignedEvents.add(id))) - if (assignedEvents.size !== events.length) { - toast.error(t('activity.allEventsMustBeAssigned')) - return - } - - setSplitting(true) - - try { - // Create event ID to index mapping - const eventIdToIndex = new Map() - events.forEach((event, index) => { - eventIdToIndex.set(event.id, index + 1) // 1-based index - }) - - // Prepare split points - const splitPoints = validGroups.map((group) => { - const groupEvents = sortedEvents.filter((e) => group.eventIds.has(e.id)) - const eventIndexes = Array.from(group.eventIds) - .map((id) => eventIdToIndex.get(id)) - .filter((idx): idx is number => idx !== undefined) - .sort((a, b) => a - b) - - return { - title: group.name, - description: groupEvents.length > 0 ? `Split from ${activity.title}` : '', - eventIndexes - } - }) - - // Call backend split API with correct structure - const response = await splitActivityHandler({ - activityId: activity.id, - splitPoints - }) - - if (!response?.success) { - console.error('[SplitActivityDialog] Split failed:', response?.error) - toast.error('Failed to split activity') - return - } - - toast.success(`Successfully split activity into ${validGroups.length} parts`) - onOpenChange(false) - onSplitSuccess?.() - } catch (error) { - console.error('[SplitActivityDialog] Split failed:', error) - toast.error('Failed to split activity') - } finally { - setSplitting(false) - } - } - - const handleCancel = () => { - if (splitting) return - onOpenChange(false) - } - - // Calculate statistics for each group - const groupStats = useMemo(() => { - return splitGroups.map((group) => { - const groupEvents = sortedEvents.filter((e) => group.eventIds.has(e.id)) - if (groupEvents.length === 0) { - return { eventCount: 0, startTime: 0, endTime: 0, duration: 0 } - } - - const startTime = groupEvents[0].startTime - const endTime = groupEvents[groupEvents.length - 1].endTime - const duration = endTime - startTime - - return { eventCount: groupEvents.length, startTime, endTime, duration } - }) - }, [splitGroups, sortedEvents]) - - if (!activity) { - return null - } - - return ( - - - - - - Split Activity - - - Split "{activity.title}" into {splitGroups.length} separate activities - - - -
- {/* Activity info */} -
-
-
{activity.title}
- {activity.description &&
{activity.description}
} -
- - {format(new Date(activity.startTime), 'HH:mm:ss')} - - - {format(new Date(activity.endTime), 'HH:mm:ss')} - · - {events.length} events -
-
-
- - {/* Split groups */} -
-
- - -
- -
- {splitGroups.map((group, groupIndex) => { - const stats = groupStats[groupIndex] - const durationMinutes = Math.floor(stats.duration / (1000 * 60)) - - return ( -
-
-
- - {group.name} -
- {splitGroups.length > 2 && ( - - )} -
- - {/* Group stats */} - {stats.eventCount > 0 && ( -
-
- - - {format(new Date(stats.startTime), 'HH:mm:ss')} -{' '} - {format(new Date(stats.endTime), 'HH:mm:ss')} - -
-
- {stats.eventCount} events · {durationMinutes}m -
-
- )} - - {/* Events in this group */} -
- {sortedEvents.map((event) => { - const isChecked = group.eventIds.has(event.id) - return ( -
- handleToggleEvent(event.id, group.id)} - disabled={splitting} - className="mt-0.5" - /> - -
- ) - })} -
-
- ) - })} -
-
- - {/* Summary */} -
-
Split Summary
-
- Activity will be split into {splitGroups.filter((g) => g.eventIds.size > 0).length} parts -
-
-
- - - - - -
-
- ) -} diff --git a/src/components/activity/TimelineDayItem.tsx b/src/components/activity/TimelineDayItem.tsx index f91932a..b5a464b 100644 --- a/src/components/activity/TimelineDayItem.tsx +++ b/src/components/activity/TimelineDayItem.tsx @@ -141,14 +141,18 @@ export function TimelineDayItem({ day, isNew: isNewProp = false }: TimelineDayIt
{day.activities.length > 0 ? (
- {day.activities.map((activity) => ( - ( +
+ className="animate-in fade-in slide-in-from-bottom-2 duration-200" + style={{ animationDelay: `${i * 50}ms`, animationFillMode: 'backwards' }}> + +
))}
) : ( diff --git a/src/components/chat/ConversationList.tsx b/src/components/chat/ConversationList.tsx index a661251..025c8c2 100644 --- a/src/components/chat/ConversationList.tsx +++ b/src/components/chat/ConversationList.tsx @@ -79,52 +79,44 @@ export function ConversationList({ } return ( -
+
+ {/* Header */} +
+ +
+ {/* Conversation list */}
{conversations.length === 0 ? (
- +

{t('chat.noConversations')}

) : ( -
- {/* New conversation button */} -
-
- -
-
-

{t('chat.newConversation')}

-
-
- +
{conversations.map((conversation) => (
onSelect(conversation.id)}> -
-
-

{conversation.title}

- - {formatDate(conversation)} - -
+
+

{conversation.title}

+ {formatDate(conversation)}
{/* Delete button */}
))} diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index 658ab8a..5c3912c 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -44,17 +44,30 @@ export function MessageInput({ const textareaRef = useRef(null) // Fetch the model list - const { models, activeModel } = useModelsStore() + const { models, activeModel, fetchModels, fetchActiveModel } = useModelsStore() const [localModelId, setLocalModelId] = useState(null) + // Ensure models are loaded + useEffect(() => { + if (models.length === 0) { + fetchModels() + } + if (!activeModel) { + fetchActiveModel() + } + }, [models.length, activeModel, fetchModels, fetchActiveModel]) + // Initialize the selected model useEffect(() => { if (selectedModelId) { setLocalModelId(selectedModelId) } else if (activeModel) { setLocalModelId(activeModel.id) + } else if (models.length > 0 && !localModelId) { + // Fallback to first model if no active model + setLocalModelId(models[0].id) } - }, [selectedModelId, activeModel]) + }, [selectedModelId, activeModel, models, localModelId]) // Handle model changes const handleModelChange = (modelId: string) => { @@ -230,16 +243,16 @@ export function MessageInput({ const modelDisplayName = currentModel?.name || activeModel?.name || 'Select Model' return ( -
+
{/* Image preview */} {images.length > 0 && ( -
+
)} {/* Input area */} -
+
{/* Text input */}