diff --git a/docs/tasks-todo/task-x-quick-entry-ai-processing.md b/docs/tasks-todo/task-x-quick-entry-ai-processing.md new file mode 100644 index 00000000..fa5eeb66 --- /dev/null +++ b/docs/tasks-todo/task-x-quick-entry-ai-processing.md @@ -0,0 +1,172 @@ +# Quick Entry AI Processing (Smart Dictation) + +**GitHub Issue:** https://github.com/dannysmith/taskdn/issues/30 +**Product:** tdn-desktop only + +## Overview + +Add AI-powered processing to the quick entry pane so users can dictate or type free-form natural language (e.g. "Create a new task in the Jengu project with a due date three weeks from now to review the meeting notes") and have it intelligently parsed into structured task fields (title, body, project, area, dates, status). + +This does not involve voice-to-text transcription — we assume users have a transcription tool (e.g. macOS dictation). This is about taking transcribed/typed text and intelligently populating the quick entry form so the user can review and confirm before saving. + +## Requirements + +### From the GitHub Issue + +- The contents of the title input field are sent to a local LLM with a short prompt, which returns structured task data for pre-populating the form. +- The prompt includes a list of current areas and projects, context about "now" (today's date), and instructions for lightly cleaning user input, extracting frontmatter fields, and generating a suitable title. +- The raw input text is always included in the body of the task doc. +- The prompt is not user-customizable. +- V1 supports only Apple Intelligence. +- There is no intent to ship downloadable LLMs or provide an interface for managing them for now. + +### Product Requirements (from discussion) + +- **Trigger:** Explicit keyboard shortcut (`Cmd+Shift+A`) + a visible button in the UI. The shortcut is only active when the quick entry pane is visible. +- **UX flow:** User opens quick pane → types/dictates free-form text → triggers AI processing → form fields are populated → user reviews and saves normally. +- **Body behavior:** The raw dictated/typed text is preserved in the body field, unless the AI-generated title is identical to the raw input (in which case no body is added, since nothing was transformed). +- **Invisible when unavailable:** If Apple Intelligence is not available (wrong platform, older macOS, not enabled), the feature must be completely invisible — no button, no shortcut, no trace. It should appear as if the feature doesn't exist. +- **Future provider support:** Don't prematurely optimise for Ollama or other providers, but at decision points, prefer architecture that wouldn't make adding them painful later. Keep the Rust-level interface clean (text + context in, structured result out). + +### UI Placement + +The quick entry pane is a compact floating card with: title input (top), metadata row with status/dates (middle), footer with project/area selectors + cancel/save (bottom). The AI processing button should sit adjacent to the title input area since that's where the action happens. + +## Background: Handy Reference Implementation + +The Handy codebase (`~/dev/handy`) has a production-grade Apple Intelligence integration. Our Swift bridge is adapted from it. + +Key files in Handy for reference: `src-tauri/swift/apple_intelligence.swift`, `src-tauri/swift/apple_intelligence_bridge.h`, `src-tauri/src/apple_intelligence.rs`, `src-tauri/build.rs`. + +Critical gotchas discovered via Handy: SIGABRT if accessing `SystemLanguageModel.default` during app init (defer to runtime); async→sync bridge via `DispatchSemaphore`; weak-link FoundationModels for older macOS compatibility; LLMs insert invisible Unicode chars (strip them); `@Generable` can fail (always have plain-text fallback). + +## Current Implementation Status + +### Completed (Phases 1-3) + +**Swift bridge:** `@Generable ParsedTask` struct with `ParsedStatus` enum, `LanguageModelSession` with structured output + plain-text fallback, availability check. Build script with SDK detection, stub compilation, weak linking. All adapted from Handy. + +**Rust layer:** Safe FFI wrapper (`apple_intelligence.rs`), Tauri commands (`commands/ai.rs`), centralized prompt templates (`commands/ai_prompts.rs`). System prompt with step-by-step field instructions, few-shot examples, and structured area→project context. Response parsing with date validation, project/area name→ID matching, body deduplication logic. + +**Frontend:** Sparkles button in title row (conditionally rendered when AI available + text entered), `Cmd+Shift+A` shortcut (active only when pane open), loading spinner, form field population from AI result. Feature is completely invisible when Apple Intelligence is unavailable. + +**Bug fix (pre-existing):** Fixed wikilinks using hash IDs instead of entity titles in all four write paths (create_task, create_project, update_task, update_project). + +### Key files + +| File | Purpose | +|------|---------| +| `src-tauri/swift/apple_intelligence.swift` | `@Generable` struct, LLM session, FFI functions | +| `src-tauri/swift/apple_intelligence_stub.swift` | Stub for builds without FoundationModels SDK | +| `src-tauri/swift/apple_intelligence_bridge.h` | C header for Swift ↔ Rust FFI | +| `src-tauri/src/apple_intelligence.rs` | Safe Rust wrapper over C FFI | +| `src-tauri/src/commands/ai.rs` | Tauri commands, response parsing, field validation | +| `src-tauri/src/commands/ai_prompts.rs` | All prompt text centralized for iteration | +| `src/components/quick-pane/QuickPaneApp.tsx` | AI processing handler, availability state | +| `src/components/quick-pane/QuickPaneTitle.tsx` | Sparkles button, loading state | +| `src/components/quick-pane/useQuickPaneKeyboard.ts` | `Cmd+Shift+A` shortcut | + +## Learnings About Apple Intelligence (~3B Model) + +These findings are from hands-on testing and WWDC25 research. They should inform all future prompt work. + +### What works well + +- **`@Generable` with `@Guide(description:)` for structured output.** The model reliably produces valid JSON matching the struct. `ParsedStatus` enum gives constrained decoding for free. +- **Few-shot examples are the single highest-impact technique.** Adding 2-3 input→output examples dramatically improved field accuracy vs. instructions alone. +- **"Empty string is the safe default" framing works.** Combined with few-shot examples showing empty fields, the model stopped hallucinating dates for simple inputs. +- **Project/area name validation in Rust catches hallucinations.** The model sometimes invents project/area names that don't exist; case-insensitive exact matching in Rust silently drops them. +- **Title generation is reliable.** The model consistently produces clean, concise titles. + +### What doesn't work + +- **Date arithmetic is unreliable.** "This Friday" from Wednesday March 25 → model returned March 30 (Monday, wrong). "Next Monday" → April 2 (Thursday, wrong). "End of the month" → October 31 (wrong month entirely). Apple explicitly says "avoid asking the model to act as a calculator." +- **Few-shot contamination.** When an input is similar to a few-shot example, the model copies fields from the example. "Submit Q1 tax return by April 15th" copied the body "Gather all receipts first" from the similar example — the input never mentioned receipts. +- **Project name fuzzy matching.** "Japan Trip" in the input didn't match "Japan Trip 2025" in the project list. The model returned empty rather than approximate-matching. Our Rust validation uses exact match only. +- **`@Guide(Regex{...})` breaks `@Generable`.** Regex constraints on date fields caused structured output to fail entirely, falling back to plain text. The `.default` model doesn't support regex-constrained generation well. Removed in favour of description-only guides. +- **`contentTagging` adapter is wrong for this task.** It's optimized for tag generation, not instruction-following. Produced topic tags ("task management, shopping") instead of following our field instructions. +- **Body generation for complex inputs.** The model sometimes fabricates body content not present in the input. + +### Key principles for prompt iteration + +1. **Positive framing outperforms negative.** "Set only if X is present" beats "Do NOT set unless X." +2. **Short instructions beat long ones.** Every token adds latency. Use `@Guide` for per-field constraints, prompt for high-level guidance. +3. **Chain-of-thought HURTS models under ~10B.** Don't ask the model to reason step-by-step. +4. **Few-shot examples need to be distinct from likely inputs** to avoid contamination. +5. **Structural constraints (enums, `@Guide(.anyOf)`) are stronger than description text** — but `.anyOf` needs compile-time values, limiting use for dynamic lists. + +## Next Steps + +### Phase 5: Evaluation Harness ✅ + +Done. 31 test cases in `commands/ai.rs` covering simple inputs, project/area matching, date extraction, status detection, complex dictation, and hallucination traps. Run with: + +``` +cd tdn-desktop/src-tauri && cargo test eval_ai --lib -- --ignored --nocapture +``` + +Current baseline: **16/31 passing**. + +### Phase 6: Auto-Ready on Quick Entry (non-AI, cherry-pickable) ✅ + +Done. `useEffect` in `QuickPaneApp.tsx` watches `[projectId, areaId, scheduled, deferUntil]` and promotes `inbox` → `ready` when `(project OR area) AND (scheduled OR defer)` are set. Standalone commit, cherry-pickable. + +### Phase 7: Deterministic Status for AI Processing ✅ + +Done. Status removed from `@Generable` struct (8→7 fields) and prompt. Status now determined by: +- Keyword detection in Rust: `blocked` / `waiting on` / `waitingon` → blocked; `icebox` / `ice box` / `ice-box` → icebox; `in progress` / `in-progress` / `inprogress` → in-progress; everything else → inbox +- Auto-ready Rule 1 (all quick entry): `useEffect` promotes inbox → ready when (project OR area) AND (scheduled OR defer) are set +- Auto-ready Rule 2 (AI only): if scheduled within 7 days and status is inbox → ready +- 8 unit tests for keyword detection in the normal test suite + +Also fixed few-shot contamination: replaced Q1 tax return example (which leaked "Gather all receipts first" into responses) with Newsletter Setup example. + +### Phase 8: Deterministic Date and Project/Area Resolution ✅ + +Done. The LLM now extracts raw date expressions ("tomorrow", "next Monday", "end of March") instead of computing YYYY-MM-DD dates. Rust resolves them deterministically via the `fuzzydate` crate with custom handlers for patterns fuzzydate doesn't support natively. + +**Date resolution (`ai_resolve.rs`):** +- `fuzzydate::parse_relative_to()` handles: today, tomorrow, day names, "this/next [day]", "Month Day" format +- Custom handlers for: "end of [month]", "end of the month", "in N weeks/days", ordinal suffixes ("15th" → "15"), "on/by [day]" prefix stripping +- Falls back to None if unparseable — user sets date manually +- 19 unit tests (deterministic, normal test suite) + +**Project/area matching (`ai_resolve.rs`):** +- Case-insensitive exact match first, then substring match (min 3 chars) +- "Japan Trip" now matches "Japan Trip 2025" via substring +- Bidirectional: checks if query is in name AND if name is in query + +**What works well now:** +- Relative dates: "tomorrow" ✓, "this Friday" ✓, "next Monday" ✓ +- Absolute dates: "April 15th" ✓, "June 1st" ✓ +- End-of-month: "end of March" ✓, "end of the month" ✓ +- Deadline detection: "due by Friday" ✓, "deadline is June 1st" ✓ + +**Remaining failures (15/31):** +- LLM sometimes returns empty for date refs despite clear language ("this afternoon", "tomorrow morning", "end of next week") — the model inconsistently extracts expressions +- LLM sometimes returns empty for project names even when explicitly mentioned — fuzzy matching helps when the LLM returns a name, but can't help when it returns empty +- LLM fills in parent area when only project should be set (hallucination) +- These are prompt refinement problems, not resolution problems + +### Phase 9: Prompt Refinement ✅ + +Iterate on the system prompt and few-shot examples to improve the LLM's extraction reliability. The eval harness (`cargo test eval_ai --lib -- --ignored --nocapture` from `src-tauri/`) makes this a fast feedback loop — edit `ai_prompts.rs`, rebuild, run eval, compare results. + +Key areas to improve: +- LLM not extracting date refs when they're present ("this afternoon" → empty, "tomorrow morning" → empty) +- LLM not returning project names even when explicitly mentioned in input +- LLM hallucinating area when only project is referenced (fills in parent area) +- Consider whether additional few-shot examples showing date ref extraction would help + +### Phase 10: Polish and Edge Cases ✅ + +- Re-processing support (user processes, edits title, processes again) +- Cancellation during processing (Escape while LLM is running) + +### Phase 11: Docs ✅ + +Done. Updated: +- `tdn-desktop/docs/developer/quick-panes.md` — added AI shortcut, auto-ready, and Apple Intelligence integration sections +- `tdn-desktop/docs/developer/apple-intelligence.md` — updated eval baseline, added async/spawn_blocking note +- `website/src/content/docs/desktop/quick-entry-pane.mdx` — added auto-ready and AI processing sections +- `website/src/content/docs/reference/desktop-reference/keyboard-shortcuts.mdx` — added `Cmd+Shift+A` shortcut diff --git a/tdn-desktop/docs/developer/apple-intelligence.md b/tdn-desktop/docs/developer/apple-intelligence.md new file mode 100644 index 00000000..4dc529dc --- /dev/null +++ b/tdn-desktop/docs/developer/apple-intelligence.md @@ -0,0 +1,281 @@ +# Apple Intelligence Quick Entry Processing + +The quick entry pane supports AI-powered processing of free-form text input using Apple's on-device Foundation Models framework (~3B parameter model). Users type or dictate natural language and the system parses it into structured task fields. + +## Availability + +- macOS 26.0+ (Tahoe) on Apple Silicon only +- Apple Intelligence must be enabled in System Settings +- Feature is completely invisible when unavailable (no button, no shortcut) +- App weak-links FoundationModels so it launches on older macOS +- Build falls back to a stub implementation when the SDK lacks FoundationModels + +## How It Works (Step by Step) + +### 1. User opens the quick pane + +User presses `Cmd+Shift+.` (global shortcut). The quick pane React app receives a focus event and loads areas, projects, and checks AI availability in parallel. The availability check goes through Rust → Swift FFI → `SystemLanguageModel.default.availability`. If Apple Intelligence is available, the sparkles button becomes visible once the user types something. + +### 2. User types and triggers AI processing + +User types or dictates free-form text into the title field (e.g. "Email James about the Japan Trip, schedule for next Monday") and clicks the sparkles button or presses `Cmd+Shift+A`. + +The frontend builds context from the loaded vault data: each project as a name + ID + parent area name (stripping wikilink brackets from the area reference), and each area as a name + ID. This context tells the LLM what projects and areas exist so it can match against them. + +### 3. Rust builds the system prompt + +The Tauri command `process_quick_entry_text` receives the raw text and context. It gets today's date and day of week, then calls `ai_prompts::build_system_prompt()` which assembles the complete prompt from: + +- A short role statement ("You are a task field extractor...") +- Today's date and day of week +- A structured list of areas and their projects (e.g. "Acme Corp: Acme Dashboard Redesign") +- Per-field instructions explaining when to set each field and when to leave it empty +- Few-shot examples showing input text → expected JSON output, including examples with empty fields + +The few-shot examples are the single highest-impact part of the prompt. They teach the model the expected output format and, critically, that leaving fields empty is the right thing to do when information isn't present. + +### 4. Rust calls Swift via C FFI + +The system prompt and user text are converted to C strings and passed through the FFI boundary to Swift. The Rust side handles all memory management — it converts to `CString` for the call and frees the response via `free_apple_llm_response` afterwards. + +### 5. Swift runs on-device inference + +The Swift function creates a `LanguageModelSession` with the system prompt as `instructions` (which the model is trained to prioritise over user input). It then calls `session.respond(to: userText, generating: ParsedTask.self)`. + +`ParsedTask` is a `@Generable` struct — this is Apple's constrained decoding system. The model's token generation is structurally constrained to produce valid output matching the struct's fields. The struct has 7 fields: title, body, dueRef, scheduledRef, deferUntilRef, project, area. Note: status is NOT included — it's handled deterministically (see step 6). + +The date fields are `*Ref` fields — the model extracts raw date expressions ("tomorrow", "next Monday", "end of March") rather than computing YYYY-MM-DD dates. Date arithmetic is done in Rust. + +If `@Generable` succeeds (the normal path), the typed `ParsedTask` struct is manually serialized to a JSON string. If it fails (rare), the function falls back to a plain `session.respond()` call — the model typically returns a JSON code block in this case. + +Because the Swift call is `async` but the C FFI is synchronous, a `DispatchSemaphore` bridges the two. A detached task runs the inference, signals the semaphore on completion, and the calling thread blocks until it's done. This takes ~2-3 seconds on Apple Silicon. + +The Tauri command is `async` and wraps the blocking FFI call in `tauri::async_runtime::spawn_blocking` to keep the main thread free (avoiding the beach ball cursor and allowing React to render the loading spinner). + +### 6. Rust parses, resolves, and validates the response + +Back in Rust, `parse_ai_response()` processes the JSON string through several stages: + +**Code fence stripping:** If the fallback path produced a markdown-wrapped JSON block (` ```json...``` `), the fences are stripped so `serde_json` can parse it. + +**Title extraction:** The model's title is used. If JSON parsing failed entirely, the original input text becomes the title. + +**Body logic:** If the model transformed the title (it differs from the original input), the original text is preserved in the body — this ensures no context from dictation is lost. If the model also generated body text, it's only appended if it contains genuinely new information. A normalisation check (`is_essentially_same`) catches cases where the model just parrots the input back with minor punctuation changes. + +**Date resolution (`ai_resolve.rs`):** The model returns raw date expressions (e.g. "tomorrow", "next Monday", "end of March"). Rust resolves these deterministically using the `fuzzydate` crate with custom handlers for patterns it doesn't support natively (ordinal suffixes, "end of [month]", "in N weeks", "on/by [day]" prefixes). Invalid or unparseable expressions become `None`. + +**Project/area matching (`ai_resolve.rs`):** The model returns a name string. Rust first tries case-insensitive exact match, then falls back to case-insensitive substring match (minimum 3 characters). This handles the common case where the model returns a truncated name ("Japan Trip" matches "Japan Trip 2025"). No match → field is left empty. + +**Status determination:** Status is NOT set by the LLM. Instead: + +1. `detect_status_from_keywords()` scans the original input text for explicit status phrases: `blocked` / `waiting on` → blocked, `icebox` / `ice box` → icebox, `in progress` / `in-progress` → in-progress. Everything else → inbox. +2. The frontend applies auto-ready rules after (see step 7). + +### 7. Frontend populates the form and applies auto-ready rules + +The React handler receives the `ParsedQuickEntry` result and sets each piece of form state: title, body (with the body section auto-expanding if populated), status, dates, project ID, and area ID. The UI updates immediately. + +Two auto-ready rules then apply: + +**Rule 1 (all quick entry, not just AI):** A `useEffect` watches projectId, areaId, scheduled, and deferUntil. If `(project OR area) AND (scheduled OR defer)` are set and status is `inbox`, it auto-promotes to `ready`. A task with both a project/area and a when-to-do-it date has been "processed" — it doesn't need the inbox. + +**Rule 2 (AI only):** After AI processing, if the scheduled date is within 7 days of today and status is still `inbox` (keyword detection didn't override), promote to `ready`. Catches "call Dave tomorrow" style tasks. + +### 8. User reviews and saves + +The user presses `Cmd+Enter` to save. From here the flow is identical to a manually-entered task: `createTask` writes the file to disk with the appropriate frontmatter, and `updateTask` adds the body content. The vault index is updated and the main window receives a `task-created` event. + +## Architecture Diagram + +``` +React (QuickPaneApp) + │ builds project/area context from loaded vault data + │ strips wikilinks from project area references + │ + ▼ +Tauri command: process_quick_entry_text() + │ builds system prompt (ai_prompts.rs) with: + │ - role + field instructions + │ - today's date + │ - area→project relationships + │ - few-shot examples + │ + ▼ +Rust FFI wrapper: apple_intelligence::process_text() + │ converts to CStrings, calls Swift, frees C memory + │ + ▼ +Swift: processTextWithSystemPrompt() + │ creates LanguageModelSession with system prompt as instructions + │ calls session.respond(to: userText, generating: ParsedTask.self) + │ ParsedTask is a @Generable struct — constrained decoding + │ falls back to plain text if @Generable fails + │ serializes to JSON, strips invisible Unicode chars + │ + ▼ +Rust: parse_ai_response() + ai_resolve + │ strips markdown code fences (fallback path) + │ parses JSON + │ resolves date expressions → YYYY-MM-DD (fuzzydate + custom) + │ matches project/area names → IDs (substring fuzzy match) + │ applies body logic (preserve original text, deduplicate) + │ detects status from keywords (not LLM) + │ + ▼ +React: populates form fields + applies auto-ready rules (Rule 1 + Rule 2) + user reviews and saves normally +``` + +## Key Components + +### Swift Bridge + +Three files in `src-tauri/swift/`: + +- `apple_intelligence.swift` — The real implementation. Contains the `@Generable ParsedTask` struct (7 fields, no status), the `LanguageModelSession` call, JSON serialization, and availability check. +- `apple_intelligence_stub.swift` — Compiled instead when the build SDK lacks FoundationModels. All functions return errors. +- `apple_intelligence_bridge.h` — C header defining the `AppleLLMResponse` struct and function signatures shared between Swift and Rust. + +The Swift code bridges async/await to synchronous C using `DispatchSemaphore` + `Task.detached`. Memory is managed manually — `strdup` for C strings, `free` on the Rust side via `free_apple_llm_response`. + +### Build Script + +`build.rs` contains `build_apple_intelligence_bridge()` (gated to macOS ARM64). It detects whether the SDK has FoundationModels, compiles the appropriate Swift file with `swiftc`, creates a static library with `libtool`, and sets up linking. Key detail: `-weak_framework FoundationModels` allows the app to launch on older macOS. + +### Rust FFI Wrapper + +`src/apple_intelligence.rs` provides safe Rust functions over the unsafe C FFI: + +- `check_availability()` → `bool` +- `process_text(system_prompt, user_content, max_tokens)` → `Result` + +### Tauri Commands + +`src/commands/ai.rs` exposes two commands to the frontend: + +- `check_apple_intelligence_available()` → `bool` +- `process_quick_entry_text(text, projects, areas)` → `Result` + +The command builds the system prompt, calls the FFI, parses the response, resolves dates and project/area names, and applies keyword-based status detection. + +### Prompt Templates + +`src/commands/ai_prompts.rs` centralizes all prompt text. This is the primary file to edit when iterating on prompt quality. It contains: + +- `build_system_prompt()` — Assembles the complete prompt from role text, context, field instructions, and few-shot examples +- `build_context_block()` — Formats areas and projects as separate lists for the prompt +- `build_examples_block()` — Few-shot input→output pairs showing raw date expression extraction + +### Date Resolution and Fuzzy Matching + +`src/commands/ai_resolve.rs` handles the deterministic parts of processing: + +- `resolve_date_expression(expr, today)` — Resolves natural language dates ("tomorrow", "next Monday", "end of March") to YYYY-MM-DD strings using the `fuzzydate` crate with custom handlers for ordinal suffixes, "end of [month]", "in N weeks", and "on/by" prefixes +- `match_project_fuzzy(name, projects)` — Case-insensitive exact match, then substring match (min 3 chars) +- `match_area_fuzzy(name, areas)` — Same approach for areas + +## The @Generable Struct + +```swift +@Generable +struct ParsedTask: Sendable { + let title: String // concise task title + let body: String // extra detail, or empty string + let project: String // project name or empty string + let area: String // area name or empty string + let scheduledRef: String // raw scheduling expression, or empty string + let dueRef: String // raw deadline expression, or empty string + let deferUntilRef: String // raw deferral expression, or empty string +} +``` + +Properties generate in declaration order. Project/area are placed before dates so the model considers them while the input is still fresh in context. + +`@Generable` uses constrained decoding — the model's token generation is structurally constrained to produce valid output matching the struct. + +Note: **status is not in the struct** — it was removed because the model was inconsistent with it. Status is now determined by keyword detection in Rust and auto-ready rules in the frontend. + +Date fields are `*Ref` fields containing raw expressions ("tomorrow", "next Monday", "end of March") rather than YYYY-MM-DD dates. The model is good at text extraction but bad at date arithmetic, so date computation is done deterministically in Rust. + +Each field has a `@Guide(description:)` annotation providing a short hint. The system prompt carries the detailed decision-making instructions. + +## Frontend Integration + +The sparkles button in `QuickPaneTitle` is conditionally rendered: `aiAvailable && value.trim().length > 0`. It shows a spinner during processing. + +The `Cmd+Shift+A` shortcut is registered in `useQuickPaneKeyboard` only when `onProcessWithAI` is provided (which only happens when AI is available). + +On successful processing, the handler populates all form state setters. The body section auto-expands if body content was generated. + +### Auto-Ready Rules + +Two rules automatically promote `inbox` → `ready`: + +**Rule 1 (all quick entry):** A `useEffect` watches `[projectId, areaId, scheduled, deferUntil]`. When `(project OR area) AND (scheduled OR defer-until)` are set and status is `inbox`, it promotes to `ready`. This applies to manual entry too — it's not AI-specific. + +**Rule 2 (AI only):** After AI processing, if the scheduled date is within 7 days of today and status is still `inbox`, promote to `ready`. Catches "call Dave tomorrow" style tasks. + +## Logging + +All AI processing is logged at INFO level with a clear delimiter: + +``` +── AI Quick Entry ────────────────────────────────── +Input: "Buy groceries for the week" +Raw response: {"title":"Buy groceries","body":"",... } +Mapped result: + title: "Buy groceries" + body: "Buy groceries for the week" + status: "inbox" + due: None + ... +──────────────────────────────────────────────────── +``` + +The full system prompt is logged at DEBUG level. To see it, check the Tauri dev server output. + +## Iterating on Prompts + +### Manual testing + +1. Edit `src/commands/ai_prompts.rs` — all prompt text is here +2. Restart the dev server (`bun run tauri:dev`) +3. Test with the quick pane +4. Check the server logs for raw response and mapped result +5. The system prompt appears at DEBUG level in logs + +The few-shot examples in `build_examples_block()` are the highest-impact thing to change. Keep examples distinct from likely real inputs to avoid contamination (the model copying example content into real responses). + +### Evaluation harness + +A faster feedback loop for prompt iteration. 31 test cases covering simple inputs, project/area matching, date extraction, status detection, complex dictation, and hallucination traps. + +``` +cd tdn-desktop/src-tauri && cargo test eval_ai --lib -- --ignored --nocapture +``` + +Takes ~50 seconds (31 LLM calls). Prints a per-case pass/fail summary with raw values and failure details. Uses fixed context (hardcoded projects, areas, date=2026-03-25 Wednesday) for reproducibility. + +The harness does NOT assert on failure — it's a measurement tool, not a hard test. Some failures are expected while iterating on prompts. + +As of March 2026, **~18/31 eval tests pass** — the remaining failures are mostly the model inconsistently extracting date expressions and project names from input. Run the eval across multiple runs to account for non-determinism. + +### Unit tests + +Deterministic logic (date resolution, fuzzy matching, keyword status detection) has standard unit tests that run in the normal test suite: + +``` +cd tdn-desktop/src-tauri && cargo test --lib +``` + +These cover date resolution patterns, fuzzy project/area matching, and keyword-based status detection. + +## Known Limitations + +- **LLM sometimes misses date expressions.** The model inconsistently extracts date references — "buy milk tomorrow" sometimes returns `scheduledRef: "tomorrow"`, sometimes returns empty. When it does extract, deterministic resolution handles it correctly. +- **LLM sometimes misses project names.** Even when a project name is explicitly in the input, the model may return empty. Fuzzy matching helps when the model returns a close-but-not-exact name, but can't help when it returns nothing. +- **Area hallucination.** When the model correctly identifies a project, it sometimes also fills in the parent area. This is harmless (both get set) but unexpected. +- **Body fabrication.** The model sometimes generates body content not present in the input. The `is_essentially_same` check catches parroting but not fabrication. +- **`@Guide(Regex{...})` is incompatible with `.default` model.** Regex constraints cause `@Generable` to fail, falling back to plain text. Use `@Guide(description:)` only. +- **`contentTagging` adapter is wrong for this task.** It produces topic tags instead of following structured extraction instructions. Use `.default`. diff --git a/tdn-desktop/docs/developer/quick-panes.md b/tdn-desktop/docs/developer/quick-panes.md index 5f8ef002..61793be3 100644 --- a/tdn-desktop/docs/developer/quick-panes.md +++ b/tdn-desktop/docs/developer/quick-panes.md @@ -84,7 +84,7 @@ src/components/quick-pane/ | ----------------- | -------------------------------------------------------------------- | | QuickPaneApp | Form state, submission logic, popover coordination, focus management | | QuickPaneCard | Visual container, CSS animations for show/hide | -| QuickPaneTitle | Title input with auto-resize | +| QuickPaneTitle | Title input with auto-resize, AI sparkle button | | QuickPaneBody | Collapsible notes with expand/collapse animation | | QuickPaneMetadata | Status and date selection (controlled popovers) | | QuickPaneFooter | Project/area selection, action buttons | @@ -109,9 +109,18 @@ src/components/quick-pane/ | `⌘ ⇧ D` | Open due date picker | | `⌃ ⇧ ⌘ D` | Open defer date picker | | `⌘ S` | Open status picker | +| `⌘ ⇧ A` | Process with AI (macOS only) | The `useQuickPaneKeyboard` hook handles all shortcuts using capture phase to intercept events before popovers receive them. +## Auto-Ready Status + +A `useEffect` in `QuickPaneApp` watches `[projectId, areaId, scheduled, deferUntil]`. When `(project OR area) AND (scheduled OR defer-until)` are set and status is `inbox`, it auto-promotes to `ready`. This applies to all quick entry (manual and AI-assisted). + +## Apple Intelligence Integration + +On macOS with Apple Intelligence, a sparkle button appears in the title row when the user has typed text. Pressing it (or `⌘⇧A`) sends the text through on-device AI processing to extract structured task fields. See `docs/developer/apple-intelligence.md` for the full architecture. + ## Platform Behavior | Platform | Panel Type | Fullscreen Overlay | Dismiss Behavior | diff --git a/tdn-desktop/src-tauri/Cargo.lock b/tdn-desktop/src-tauri/Cargo.lock index e7ae60dd..57ce1c2c 100644 --- a/tdn-desktop/src-tauri/Cargo.lock +++ b/tdn-desktop/src-tauri/Cargo.lock @@ -124,7 +124,7 @@ dependencies = [ "objc2-foundation", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "wl-clipboard-rs", "x11rb", ] @@ -964,7 +964,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1175,7 +1175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1460,6 +1460,17 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzydate" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51ddfc7f800df80b58d70db49cd96a1567c372c65bc8e4fa1ba728a9741426a2" +dependencies = [ + "chrono", + "lazy_static", + "thiserror 2.0.17", +] + [[package]] name = "fxhash" version = "0.2.1" @@ -3090,7 +3101,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -3615,7 +3626,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -4013,7 +4024,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4721,6 +4732,7 @@ version = "0.1.2" dependencies = [ "chrono", "dirs", + "fuzzydate", "globset", "gray_matter", "log", @@ -5299,7 +5311,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6218,7 +6230,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/tdn-desktop/src-tauri/Cargo.toml b/tdn-desktop/src-tauri/Cargo.toml index df61707c..759bd5d7 100644 --- a/tdn-desktop/src-tauri/Cargo.toml +++ b/tdn-desktop/src-tauri/Cargo.toml @@ -54,6 +54,7 @@ specta = { version = "=2.0.0-rc.22", features = ["derive", "serde_json"] } tauri-specta = { version = "=2.0.0-rc.21", features = ["typescript"] } specta-typescript = "=0.0.9" tauri-plugin-deep-link = "2" +fuzzydate = "0.4.0" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-single-instance = "2" diff --git a/tdn-desktop/src-tauri/build.rs b/tdn-desktop/src-tauri/build.rs index d860e1e6..29908656 100644 --- a/tdn-desktop/src-tauri/build.rs +++ b/tdn-desktop/src-tauri/build.rs @@ -1,3 +1,144 @@ fn main() { + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + build_apple_intelligence_bridge(); + tauri_build::build() } + +/// Build the Swift ↔ Rust bridge for Apple Intelligence. +/// +/// Detects whether the current Xcode SDK includes the FoundationModels framework. +/// If it does, compiles the real implementation; otherwise compiles a stub that +/// returns "unavailable" for all calls. +/// +/// The app uses weak linking for FoundationModels so it can launch on older macOS +/// versions — runtime availability is checked via @available(macOS 26.0, *). +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +fn build_apple_intelligence_bridge() { + use std::path::{Path, PathBuf}; + use std::process::Command; + + const REAL_SWIFT_FILE: &str = "swift/apple_intelligence.swift"; + const STUB_SWIFT_FILE: &str = "swift/apple_intelligence_stub.swift"; + const BRIDGE_HEADER: &str = "swift/apple_intelligence_bridge.h"; + + println!("cargo:rerun-if-changed={REAL_SWIFT_FILE}"); + println!("cargo:rerun-if-changed={STUB_SWIFT_FILE}"); + println!("cargo:rerun-if-changed={BRIDGE_HEADER}"); + + let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR not set")); + let object_path = out_dir.join("apple_intelligence.o"); + let static_lib_path = out_dir.join("libapple_intelligence.a"); + + let sdk_output = Command::new("xcrun") + .args(["--sdk", "macosx", "--show-sdk-path"]) + .output() + .expect("Failed to run xcrun"); + if !sdk_output.status.success() { + let stderr = String::from_utf8_lossy(&sdk_output.stderr); + panic!("xcrun --show-sdk-path failed: {stderr}"); + } + let sdk_path = String::from_utf8(sdk_output.stdout) + .expect("SDK path is not valid UTF-8") + .trim() + .to_string(); + + // Check if the SDK supports FoundationModels (required for Apple Intelligence) + let framework_path = + Path::new(&sdk_path).join("System/Library/Frameworks/FoundationModels.framework"); + let has_foundation_models = framework_path.exists(); + + let source_file = if has_foundation_models { + println!("cargo:warning=Building with Apple Intelligence support."); + REAL_SWIFT_FILE + } else { + println!("cargo:warning=Apple Intelligence SDK not found. Building with stubs."); + STUB_SWIFT_FILE + }; + + if !Path::new(source_file).exists() { + panic!("Source file {source_file} is missing!"); + } + + let swiftc_output = Command::new("xcrun") + .args(["--find", "swiftc"]) + .output() + .expect("Failed to run xcrun --find swiftc"); + if !swiftc_output.status.success() { + let stderr = String::from_utf8_lossy(&swiftc_output.stderr); + panic!("xcrun --find swiftc failed: {stderr}"); + } + let swiftc_path = String::from_utf8(swiftc_output.stdout) + .expect("swiftc path is not valid UTF-8") + .trim() + .to_string(); + + let toolchain_swift_lib = Path::new(&swiftc_path) + .parent() + .and_then(|p| p.parent()) + .map(|root| root.join("lib/swift/macosx")) + .expect("Unable to determine Swift toolchain lib directory"); + let sdk_swift_lib = Path::new(&sdk_path).join("usr/lib/swift"); + + // Use macOS 11.0 as deployment target for compatibility. + // The @available(macOS 26.0, *) checks in Swift handle runtime availability. + // Weak linking for FoundationModels is handled via cargo:rustc-link-arg below. + let status = Command::new("xcrun") + .args([ + "swiftc", + "-target", + "arm64-apple-macosx11.0", + "-sdk", + &sdk_path, + "-O", + "-import-objc-header", + BRIDGE_HEADER, + "-c", + source_file, + "-o", + object_path + .to_str() + .expect("Failed to convert object path to string"), + ]) + .status() + .expect("Failed to invoke swiftc for Apple Intelligence bridge"); + + if !status.success() { + panic!("swiftc failed to compile {source_file}"); + } + + let status = Command::new("libtool") + .args([ + "-static", + "-o", + static_lib_path + .to_str() + .expect("Failed to convert static lib path to string"), + object_path + .to_str() + .expect("Failed to convert object path to string"), + ]) + .status() + .expect("Failed to create static library for Apple Intelligence bridge"); + + if !status.success() { + panic!("libtool failed for Apple Intelligence bridge"); + } + + println!("cargo:rustc-link-search=native={}", out_dir.display()); + println!("cargo:rustc-link-lib=static=apple_intelligence"); + println!( + "cargo:rustc-link-search=native={}", + toolchain_swift_lib.display() + ); + println!("cargo:rustc-link-search=native={}", sdk_swift_lib.display()); + println!("cargo:rustc-link-lib=framework=Foundation"); + + if has_foundation_models { + // Use weak linking so the app can launch on systems without FoundationModels + println!("cargo:rustc-link-arg=-weak_framework"); + println!("cargo:rustc-link-arg=FoundationModels"); + } + + println!("cargo:rustc-link-arg=-Wl,-rpath,/usr/lib/swift"); +} diff --git a/tdn-desktop/src-tauri/src/apple_intelligence.rs b/tdn-desktop/src-tauri/src/apple_intelligence.rs new file mode 100644 index 00000000..6984048d --- /dev/null +++ b/tdn-desktop/src-tauri/src/apple_intelligence.rs @@ -0,0 +1,73 @@ +//! Safe Rust wrapper over the Apple Intelligence Swift FFI bridge. +//! +//! On macOS ARM64, this links to Swift functions that call Apple's +//! FoundationModels framework. On other platforms, these functions +//! are not available and the module is not compiled. + +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int}; + +/// C-compatible response structure from Swift. +#[repr(C)] +pub struct AppleLLMResponse { + pub response: *mut c_char, + pub success: c_int, + pub error_message: *mut c_char, +} + +extern "C" { + pub fn is_apple_intelligence_available() -> c_int; + pub fn process_text_with_system_prompt_apple( + system_prompt: *const c_char, + user_content: *const c_char, + max_tokens: i32, + ) -> *mut AppleLLMResponse; + pub fn free_apple_llm_response(response: *mut AppleLLMResponse); +} + +/// Check if Apple Intelligence is available on this device. +pub fn check_availability() -> bool { + unsafe { is_apple_intelligence_available() == 1 } +} + +/// Process text with Apple Intelligence using a system prompt and user content. +/// Returns the model's response as a string, or an error message. +pub fn process_text( + system_prompt: &str, + user_content: &str, + max_tokens: i32, +) -> Result { + let system_cstr = CString::new(system_prompt).map_err(|e| e.to_string())?; + let user_cstr = CString::new(user_content).map_err(|e| e.to_string())?; + + let response_ptr = unsafe { + process_text_with_system_prompt_apple(system_cstr.as_ptr(), user_cstr.as_ptr(), max_tokens) + }; + + if response_ptr.is_null() { + return Err("Null response from Apple LLM".to_string()); + } + + let response = unsafe { &*response_ptr }; + + let result = if response.success == 1 { + if response.response.is_null() { + Ok(String::new()) + } else { + let c_str = unsafe { CStr::from_ptr(response.response) }; + Ok(c_str.to_string_lossy().into_owned()) + } + } else { + let error_msg = if !response.error_message.is_null() { + let c_str = unsafe { CStr::from_ptr(response.error_message) }; + c_str.to_string_lossy().into_owned() + } else { + "Unknown error".to_string() + }; + Err(error_msg) + }; + + unsafe { free_apple_llm_response(response_ptr) }; + + result +} diff --git a/tdn-desktop/src-tauri/src/bindings.rs b/tdn-desktop/src-tauri/src/bindings.rs index a3b14ff1..20d75cf8 100644 --- a/tdn-desktop/src-tauri/src/bindings.rs +++ b/tdn-desktop/src-tauri/src/bindings.rs @@ -1,7 +1,7 @@ use tauri_specta::{collect_commands, Builder}; pub fn generate_bindings() -> Builder { - use crate::commands::{config, notifications, preferences, quick_pane, recovery, vault}; + use crate::commands::{ai, config, notifications, preferences, quick_pane, recovery, vault}; Builder::::new().commands(collect_commands![ preferences::greet, @@ -37,6 +37,9 @@ pub fn generate_bindings() -> Builder { vault::update_project, vault::delete_task, vault::get_entity_raw_content, + // AI commands + ai::check_apple_intelligence_available, + ai::process_quick_entry_text, ]) } diff --git a/tdn-desktop/src-tauri/src/commands/ai.rs b/tdn-desktop/src-tauri/src/commands/ai.rs new file mode 100644 index 00000000..0bced2a5 --- /dev/null +++ b/tdn-desktop/src-tauri/src/commands/ai.rs @@ -0,0 +1,1161 @@ +//! Tauri commands for Apple Intelligence integration. +//! +//! Provides AI-powered processing of free-form text input in the quick entry pane, +//! parsing dictated/typed text into structured task fields. + +use serde::{Deserialize, Serialize}; +use specta::Type; + +/// Result of AI-processing free-form text into structured task fields. +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ParsedQuickEntry { + pub title: String, + pub body: String, + pub status: String, + pub due: Option, + pub scheduled: Option, + pub defer_until: Option, + /// Matched project ID (if a project name was recognised) + pub project_id: Option, + /// Matched area ID (if an area name was recognised) + pub area_id: Option, +} + +/// A name+ID pair for passing project/area context to the AI processor. +#[derive(Debug, Clone, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct NameIdPair { + pub id: String, + pub name: String, +} + +/// A project with its area relationship for richer AI context. +#[derive(Debug, Clone, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ProjectContext { + pub id: String, + pub name: String, + /// The area name this project belongs to (if any) + pub area_name: Option, +} + +/// Check if Apple Intelligence is available on this device. +#[tauri::command] +#[specta::specta] +pub fn check_apple_intelligence_available() -> bool { + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + { + crate::apple_intelligence::check_availability() + } + #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] + { + false + } +} + +/// Process free-form text input using Apple Intelligence to extract structured task fields. +/// +/// Takes the raw text from the quick entry title field, plus lists of available +/// projects (with area relationships) and areas for context. +/// +/// This command is async to avoid blocking the main thread — the Swift FFI call +/// uses a DispatchSemaphore which blocks for 2-3 seconds during inference. +#[tauri::command] +#[specta::specta] +pub async fn process_quick_entry_text( + text: String, + projects: Vec, + areas: Vec, +) -> Result { + // Move the blocking FFI work off the main thread + tauri::async_runtime::spawn_blocking(move || { + process_quick_entry_text_sync(&text, &projects, &areas) + }) + .await + .map_err(|e| format!("Task join error: {e}"))? +} + +/// Synchronous implementation — called from spawn_blocking and from the eval harness. +pub(crate) fn process_quick_entry_text_sync( + text: &str, + projects: &[ProjectContext], + areas: &[NameIdPair], +) -> Result { + let trimmed = text.trim(); + if trimmed.is_empty() { + return Err("No text to process".to_string()); + } + + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + { + let today = chrono::Local::now(); + let date_str = today.format("%Y-%m-%d").to_string(); + let day_of_week = today.format("%A").to_string(); + + let projects_with_areas: Vec = projects + .iter() + .map(|p| super::ai_prompts::ProjectWithArea { + name: p.name.clone(), + area_name: p.area_name.clone(), + }) + .collect(); + + let system_prompt = super::ai_prompts::build_system_prompt( + &projects_with_areas, + areas, + &date_str, + &day_of_week, + ); + + log::info!("AI Quick Entry: processing input"); + log::debug!("── AI Quick Entry ──────────────────────────────────"); + log::debug!("Input: {trimmed:?}"); + log::debug!("System prompt:\n{system_prompt}"); + + let response = crate::apple_intelligence::process_text(&system_prompt, trimmed, 0)?; + + log::debug!("Raw response: {response}"); + + let mut result = + parse_ai_response(&response, trimmed, projects, areas, today.date_naive())?; + + // Determine status via keyword detection (not LLM) + result.status = detect_status_from_keywords(trimmed).to_string(); + + log::debug!("Mapped result:"); + log::debug!(" title: {:?}", result.title); + log::debug!(" status: {:?}", result.status); + log::debug!(" due: {:?}", result.due); + log::debug!(" scheduled: {:?}", result.scheduled); + log::debug!(" defer: {:?}", result.defer_until); + log::debug!(" project: {:?}", result.project_id); + log::debug!(" area: {:?}", result.area_id); + log::debug!("────────────────────────────────────────────────────"); + log::info!("AI Quick Entry: complete"); + + Ok(result) + } + + #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] + { + let _ = (projects, areas); + Err("Apple Intelligence is not available on this platform".to_string()) + } +} + +/// Strip markdown code fences from a response (e.g. ```json\n{...}\n```) +fn strip_code_fences(s: &str) -> &str { + let trimmed = s.trim(); + if let Some(rest) = trimmed.strip_prefix("```") { + // Skip the language tag (e.g. "json") on the first line + let after_tag = rest.find('\n').map(|i| &rest[i + 1..]).unwrap_or(rest); + // Strip trailing fence + after_tag.strip_suffix("```").unwrap_or(after_tag).trim() + } else { + trimmed + } +} + +/// Parse the AI response JSON into a `ParsedQuickEntry`, resolving project/area names to IDs. +/// `today` is used for resolving relative date expressions. +fn parse_ai_response( + response: &str, + original_text: &str, + projects: &[ProjectContext], + areas: &[NameIdPair], + today: chrono::NaiveDate, +) -> Result { + // Try to parse as JSON (structured output from @Generable). + // Also handles fallback where model returns JSON wrapped in markdown code fences. + let clean_response = strip_code_fences(response); + if let Ok(parsed) = serde_json::from_str::(clean_response) { + let raw_title = parsed["title"].as_str().unwrap_or("").trim(); + let title = if raw_title.is_empty() { + original_text.trim().to_string() + } else { + raw_title.to_string() + }; + + let body_from_ai = parsed["body"].as_str().unwrap_or("").trim().to_string(); + + // Determine body: include original text unless title is identical to input + let body = if title.eq_ignore_ascii_case(original_text.trim()) { + // Title is the same as input — no need to duplicate in body + // But only use AI body if it adds new information + if is_essentially_same(&body_from_ai, original_text.trim()) { + String::new() + } else { + body_from_ai + } + } else { + // Title was transformed — preserve original text in body + // Don't append AI body if it's just parroting the input + if body_from_ai.is_empty() || is_essentially_same(&body_from_ai, original_text.trim()) { + original_text.trim().to_string() + } else { + let original_trimmed = original_text.trim(); + format!("{original_trimmed}\n\n{body_from_ai}") + } + }; + + // Status is determined by keyword detection, not the LLM + let status = "inbox".to_string(); + + // Resolve date expressions deterministically + let due_ref = parsed["dueRef"].as_str().unwrap_or("").trim(); + let scheduled_ref = parsed["scheduledRef"].as_str().unwrap_or("").trim(); + let defer_ref = parsed["deferUntilRef"].as_str().unwrap_or("").trim(); + + let due = super::ai_resolve::resolve_date_expression(due_ref, today); + let scheduled = super::ai_resolve::resolve_date_expression(scheduled_ref, today); + let defer_until = super::ai_resolve::resolve_date_expression(defer_ref, today); + + // Match project/area names with fuzzy (substring) matching + let project_name = parsed["project"].as_str().unwrap_or("").trim(); + let project_id = super::ai_resolve::match_project_fuzzy(project_name, projects); + + let area_name = parsed["area"].as_str().unwrap_or("").trim(); + let area_id = super::ai_resolve::match_area_fuzzy(area_name, areas); + + Ok(ParsedQuickEntry { + title, + body, + status, + due, + scheduled, + defer_until, + project_id, + area_id, + }) + } else { + // Fallback: structured output failed, model returned plain text. + // Use the original text as title and the AI response as body context. + log::warn!("AI returned non-JSON response, using fallback parsing"); + Ok(ParsedQuickEntry { + title: original_text.trim().to_string(), + body: response.trim().to_string(), + status: "inbox".to_string(), + due: None, + scheduled: None, + defer_until: None, + project_id: None, + area_id: None, + }) + } +} + +/// Check if two strings are essentially the same (ignoring case, trailing punctuation, whitespace). +/// Used to avoid duplicating content when the AI parrots back the input. +fn is_essentially_same(a: &str, b: &str) -> bool { + if a.is_empty() || b.is_empty() { + return a.is_empty() && b.is_empty(); + } + let normalize = |s: &str| s.trim().trim_end_matches(['.', '!', '?']).to_lowercase(); + normalize(a) == normalize(b) +} + +// ============================================================================= +// Keyword-Based Status Detection +// ============================================================================= + +/// Detect task status from explicit keywords in the input text. +/// Only matches unambiguous, explicit status language. Returns "inbox" by default. +/// +/// This is intentionally narrow — false negatives (missing an icebox intent) are +/// harmless since the user can change the status dropdown in half a second. +/// False positives (wrongly setting blocked/icebox) are more disruptive. +pub fn detect_status_from_keywords(input: &str) -> &'static str { + let lower = input.to_lowercase(); + + // Use word-boundary matching to avoid false positives like "unblocked" + let has_word = |word: &str| { + lower + .find(word) + .map(|pos| { + let before = if pos == 0 { + true + } else { + !lower.as_bytes()[pos - 1].is_ascii_alphanumeric() + }; + let after_pos = pos + word.len(); + let after = if after_pos >= lower.len() { + true + } else { + !lower.as_bytes()[after_pos].is_ascii_alphanumeric() + }; + before && after + }) + .unwrap_or(false) + }; + + // Check for blocked — explicit blocking language + if has_word("blocked") || lower.contains("waiting on") || lower.contains("waitingon") { + return "blocked"; + } + + // Check for icebox — only very explicit mentions + if has_word("icebox") || lower.contains("ice box") || lower.contains("ice-box") { + return "icebox"; + } + + // Check for in-progress — explicit mentions only + if lower.contains("in progress") + || lower.contains("in-progress") + || lower.contains("inprogress") + { + return "in-progress"; + } + + "inbox" +} + +// ============================================================================= +// Unit Tests (deterministic, runs in normal test suite) +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keyword_default_is_inbox() { + assert_eq!(detect_status_from_keywords("Buy groceries"), "inbox"); + assert_eq!( + detect_status_from_keywords("Call the dentist tomorrow"), + "inbox" + ); + assert_eq!(detect_status_from_keywords("Review the mockups"), "inbox"); + } + + #[test] + fn keyword_detects_blocked() { + assert_eq!( + detect_status_from_keywords("This is blocked by the security review"), + "blocked" + ); + assert_eq!( + detect_status_from_keywords("Waiting on the client to respond"), + "blocked" + ); + assert_eq!( + detect_status_from_keywords("waitingon client response"), + "blocked" + ); + } + + #[test] + fn keyword_blocked_is_narrow() { + assert_eq!( + detect_status_from_keywords("Can't proceed until we get approval"), + "inbox" + ); + assert_eq!( + detect_status_from_keywords("Stuck on the API migration"), + "inbox" + ); + } + + #[test] + fn keyword_detects_icebox() { + assert_eq!( + detect_status_from_keywords("Icebox task to learn piano"), + "icebox" + ); + assert_eq!( + detect_status_from_keywords("Put this in the ice box"), + "icebox" + ); + assert_eq!( + detect_status_from_keywords("ice-box this for later"), + "icebox" + ); + } + + #[test] + fn keyword_icebox_is_narrow() { + assert_eq!(detect_status_from_keywords("Maybe call the bank"), "inbox"); + assert_eq!( + detect_status_from_keywords("I might need to do this"), + "inbox" + ); + assert_eq!(detect_status_from_keywords("One day learn guitar"), "inbox"); + assert_eq!( + detect_status_from_keywords("Eventually get around to it"), + "inbox" + ); + } + + #[test] + fn keyword_detects_in_progress() { + assert_eq!( + detect_status_from_keywords("This is in progress"), + "in-progress" + ); + assert_eq!( + detect_status_from_keywords("Mark as in-progress"), + "in-progress" + ); + assert_eq!( + detect_status_from_keywords("inprogress task"), + "in-progress" + ); + } + + #[test] + fn keyword_in_progress_is_narrow() { + assert_eq!( + detect_status_from_keywords("Already started the refactor"), + "inbox" + ); + assert_eq!( + detect_status_from_keywords("Working on the dashboard"), + "inbox" + ); + } + + #[test] + fn keyword_case_insensitive() { + assert_eq!(detect_status_from_keywords("This is BLOCKED"), "blocked"); + assert_eq!(detect_status_from_keywords("ICEBOX this task"), "icebox"); + assert_eq!( + detect_status_from_keywords("IN PROGRESS refactor"), + "in-progress" + ); + } + + #[test] + fn keyword_no_false_positive_on_unblocked() { + assert_eq!( + detect_status_from_keywords("This task is now unblocked"), + "inbox" + ); + } + + // ── Parsing helper tests ───────────────────────────────────────────── + + #[test] + fn strip_code_fences_plain_json() { + let json = r#"{"title":"Buy milk"}"#; + assert_eq!(strip_code_fences(json), json); + } + + #[test] + fn strip_code_fences_markdown_wrapped() { + let input = "```json\n{\"title\":\"Buy milk\"}\n```"; + assert_eq!(strip_code_fences(input), r#"{"title":"Buy milk"}"#); + } + + #[test] + fn strip_code_fences_no_language_tag() { + let input = "```\n{\"title\":\"Buy milk\"}\n```"; + assert_eq!(strip_code_fences(input), r#"{"title":"Buy milk"}"#); + } + + #[test] + fn is_essentially_same_basic() { + assert!(is_essentially_same("hello", "hello")); + assert!(is_essentially_same("Hello", "hello")); + assert!(is_essentially_same("hello.", "hello")); + assert!(is_essentially_same("hello!", "Hello")); + assert!(!is_essentially_same("hello", "world")); + } + + #[test] + fn is_essentially_same_empty() { + assert!(is_essentially_same("", "")); + assert!(!is_essentially_same("hello", "")); + assert!(!is_essentially_same("", "hello")); + } + + #[test] + fn parse_ai_response_empty_title_falls_back() { + let today = chrono::NaiveDate::from_ymd_opt(2026, 3, 25).unwrap(); + let response = r#"{"title":"","body":"","project":"","area":"","scheduledRef":"","dueRef":"","deferUntilRef":""}"#; + let result = parse_ai_response(response, "Buy milk", &[], &[], today).unwrap(); + assert_eq!(result.title, "Buy milk"); + } + + #[test] + fn parse_ai_response_non_json_fallback() { + let today = chrono::NaiveDate::from_ymd_opt(2026, 3, 25).unwrap(); + let response = "This is not JSON at all"; + let result = parse_ai_response(response, "Buy milk", &[], &[], today).unwrap(); + assert_eq!(result.title, "Buy milk"); + assert_eq!(result.body, "This is not JSON at all"); + } +} + +// ============================================================================= +// Evaluation Harness +// ============================================================================= +// +// A development tool for iterating on prompt quality. NOT part of the normal +// test suite — requires a live Apple Intelligence model on the device. +// +// Run with: cargo test -p taskdn-desktop eval_ai -- --ignored --nocapture +// +// Each test case sends real text through the full pipeline (prompt building → +// Apple Intelligence → response parsing) and compares against expectations. + +#[cfg(test)] +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +mod eval { + use super::*; + + // ── Fixed context for reproducible evaluation ──────────────────────── + + const EVAL_DATE: &str = "2026-03-25"; + const EVAL_DAY: &str = "Wednesday"; + + fn eval_projects() -> Vec { + vec![ + ProjectContext { + id: "p-japan".into(), + name: "Japan Trip 2025".into(), + area_name: Some("Travel".into()), + }, + ProjectContext { + id: "p-acme".into(), + name: "Acme Dashboard Redesign".into(), + area_name: Some("Acme Corp".into()), + }, + ProjectContext { + id: "p-tax".into(), + name: "Q1 Tax Preparation".into(), + area_name: Some("Finance".into()), + }, + ProjectContext { + id: "p-blog".into(), + name: "Tech Blog Relaunch".into(), + area_name: Some("Writing".into()), + }, + ProjectContext { + id: "p-cli".into(), + name: "Open Source CLI Tool".into(), + area_name: Some("Coding".into()), + }, + ProjectContext { + id: "p-marathon".into(), + name: "Half Marathon Training".into(), + area_name: Some("Health".into()), + }, + ProjectContext { + id: "p-office".into(), + name: "Home Office Setup".into(), + area_name: Some("Home".into()), + }, + ProjectContext { + id: "p-garden".into(), + name: "Garden Renovation".into(), + area_name: Some("Home".into()), + }, + ProjectContext { + id: "p-newsletter".into(), + name: "Newsletter Setup".into(), + area_name: Some("Writing".into()), + }, + ProjectContext { + id: "p-rust".into(), + name: "Learn Rust".into(), + area_name: Some("Learning".into()), + }, + ] + } + + fn eval_areas() -> Vec { + vec![ + NameIdPair { + id: "a-travel".into(), + name: "Travel".into(), + }, + NameIdPair { + id: "a-acme".into(), + name: "Acme Corp".into(), + }, + NameIdPair { + id: "a-finance".into(), + name: "Finance".into(), + }, + NameIdPair { + id: "a-writing".into(), + name: "Writing".into(), + }, + NameIdPair { + id: "a-coding".into(), + name: "Coding".into(), + }, + NameIdPair { + id: "a-health".into(), + name: "Health".into(), + }, + NameIdPair { + id: "a-home".into(), + name: "Home".into(), + }, + NameIdPair { + id: "a-learning".into(), + name: "Learning".into(), + }, + NameIdPair { + id: "a-marketing".into(), + name: "Marketing".into(), + }, + ] + } + + // ── Expected output specification ──────────────────────────────────── + + struct Expected { + /// Substring that must appear in the title (case-insensitive) + title_contains: &'static str, + /// Expected status value + status: &'static str, + /// Expected project ID (None = must be empty) + project: Option<&'static str>, + /// Expected area ID (None = must be empty) + area: Option<&'static str>, + /// Expected scheduled date (None = must be empty) + scheduled: Option<&'static str>, + /// Expected due date (None = must be empty) + due: Option<&'static str>, + /// Expected defer date (None = must be empty) + defer: Option<&'static str>, + /// Body check: Some(true) = must be empty, Some(false) = must have content, None = don't check. + /// Note: body is populated by deterministic Rust code (original text preserved when title + /// is transformed), so this mostly tests our code, not the LLM. Use None for most cases. + body_empty: Option, + } + + // ── Test runner ────────────────────────────────────────────────────── + + fn run_eval(input: &str, expected: &Expected) -> (ParsedQuickEntry, Vec) { + let projects = eval_projects(); + let areas = eval_areas(); + + let projects_with_areas: Vec = projects + .iter() + .map(|p| super::super::ai_prompts::ProjectWithArea { + name: p.name.clone(), + area_name: p.area_name.clone(), + }) + .collect(); + + let system_prompt = super::super::ai_prompts::build_system_prompt( + &projects_with_areas, + &areas, + EVAL_DATE, + EVAL_DAY, + ); + + let response = crate::apple_intelligence::process_text(&system_prompt, input, 0) + .expect("Apple Intelligence call failed"); + + let eval_today = chrono::NaiveDate::parse_from_str(EVAL_DATE, "%Y-%m-%d").unwrap(); + let mut result = parse_ai_response(&response, input, &projects, &areas, eval_today) + .expect("Response parsing failed"); + + // Apply keyword detection (same as production code path) + result.status = detect_status_from_keywords(input).to_string(); + + let mut failures = Vec::new(); + + // Check title + if !result + .title + .to_lowercase() + .contains(&expected.title_contains.to_lowercase()) + { + failures.push(format!( + "title: expected to contain {:?}, got {:?}", + expected.title_contains, result.title + )); + } + + // Check status + if result.status != expected.status { + failures.push(format!( + "status: expected {:?}, got {:?}", + expected.status, result.status + )); + } + + // Check project + match expected.project { + Some(id) => { + if result.project_id.as_deref() != Some(id) { + failures.push(format!( + "project: expected Some({:?}), got {:?}", + id, result.project_id + )); + } + } + None => { + if result.project_id.is_some() { + failures.push(format!( + "project: expected None, got {:?}", + result.project_id + )); + } + } + } + + // Check area + match expected.area { + Some(id) => { + if result.area_id.as_deref() != Some(id) { + failures.push(format!( + "area: expected Some({:?}), got {:?}", + id, result.area_id + )); + } + } + None => { + if result.area_id.is_some() { + failures.push(format!("area: expected None, got {:?}", result.area_id)); + } + } + } + + // Check dates + check_date_field( + "scheduled", + &result.scheduled, + expected.scheduled, + &mut failures, + ); + check_date_field("due", &result.due, expected.due, &mut failures); + check_date_field("defer", &result.defer_until, expected.defer, &mut failures); + + // Check body + match expected.body_empty { + Some(true) if !result.body.is_empty() => { + failures.push(format!("body: expected empty, got {:?}", result.body)); + } + Some(false) if result.body.is_empty() => { + failures.push("body: expected content, got empty".to_string()); + } + _ => {} // None = don't check + } + + (result, failures) + } + + fn check_date_field( + name: &str, + actual: &Option, + expected: Option<&str>, + failures: &mut Vec, + ) { + match expected { + Some(date) => { + if actual.as_deref() != Some(date) { + failures.push(format!("{name}: expected Some({date:?}), got {actual:?}")); + } + } + None => { + if actual.is_some() { + failures.push(format!("{name}: expected None, got {actual:?}")); + } + } + } + } + + // ── The eval suite ─────────────────────────────────────────────────── + + #[test] + #[ignore] + fn eval_ai() { + // Note: EVAL_DATE is 2026-03-25, a Wednesday. + // Thu=26, Fri=27, Sat=28, Sun=29, Mon=30, Tue=31, Wed Apr 1, Thu=2, Fri=3 + // + // body_empty: None means "don't check body" — body content is determined by + // deterministic Rust code (if title differs from input, original text goes in + // body), so it's not testing LLM quality. Use Some(true) only when the title + // is very likely to be identical to input (i.e. input is already a clean title). + let cases: Vec<(&str, Expected)> = + vec![ + + // ============================================================= + // SIMPLE INPUTS — no metadata expected + // ============================================================= + + ( + "Buy groceries for the week", + Expected { + title_contains: "groceries", status: "inbox", + project: None, area: None, + scheduled: None, due: None, defer: None, + body_empty: None, // title may or may not be shortened + }, + ), + ( + "Look into upgrading the database", + Expected { + title_contains: "database", status: "inbox", + project: None, area: None, + scheduled: None, due: None, defer: None, + body_empty: None, + }, + ), + ( + "Remember to water the plants", + Expected { + title_contains: "water", status: "inbox", + project: None, area: None, + scheduled: None, due: None, defer: None, + body_empty: None, + }, + ), + ( + "Think about what to get mum for her birthday", + Expected { + title_contains: "mum", status: "inbox", + project: None, area: None, + scheduled: None, due: None, defer: None, + body_empty: None, + }, + ), + + // ============================================================= + // PROJECT MATCHING — explicit project names in input + // ============================================================= + + ( + "Review the Acme Dashboard Redesign mockups", + Expected { + title_contains: "mockup", status: "inbox", + project: Some("p-acme"), area: None, + scheduled: None, due: None, defer: None, + body_empty: None, + }, + ), + ( + "Write a blog post for the Tech Blog Relaunch", + Expected { + title_contains: "blog", status: "inbox", + project: Some("p-blog"), area: None, + scheduled: None, due: None, defer: None, + body_empty: None, + }, + ), + ( + "Check the Open Source CLI Tool issue tracker", + Expected { + title_contains: "CLI", status: "inbox", + project: Some("p-cli"), area: None, + scheduled: None, due: None, defer: None, + body_empty: None, + }, + ), + // Partial name — "Japan Trip" should match "Japan Trip 2025" + // (currently fails with exact matching — tests fuzzy matching improvement) + ( + "Email James about the Japan Trip", + Expected { + title_contains: "James", status: "inbox", + project: Some("p-japan"), area: None, + scheduled: None, due: None, defer: None, + body_empty: None, + }, + ), + + // ============================================================= + // AREA MATCHING — explicit area names in input + // ============================================================= + + ( + "Send the January invoice to Acme Corp", + Expected { + title_contains: "invoice", status: "inbox", + project: None, area: Some("a-acme"), + scheduled: None, due: None, defer: None, + body_empty: None, + }, + ), + + // ============================================================= + // SCHEDULED DATES — "tomorrow" variations + // ============================================================= + + ( + "Call the dentist tomorrow about that crown", + Expected { + title_contains: "dentist", status: "inbox", + project: None, area: None, + scheduled: Some("2026-03-26"), due: None, defer: None, + body_empty: None, + }, + ), + ( + "Pick up the dry cleaning tomorrow", + Expected { + title_contains: "dry cleaning", status: "inbox", + project: None, area: None, + scheduled: Some("2026-03-26"), due: None, defer: None, + body_empty: None, + }, + ), + ( + "Send that email to Sarah tomorrow morning", + Expected { + title_contains: "Sarah", status: "inbox", + project: None, area: None, + scheduled: Some("2026-03-26"), due: None, defer: None, + body_empty: None, + }, + ), + + // ============================================================= + // SCHEDULED DATES — "this Friday" / "next Monday" / specific days + // ============================================================= + + ( + "Schedule a team meeting for this Friday", + Expected { + title_contains: "meeting", status: "inbox", + project: None, area: None, + scheduled: Some("2026-03-27"), due: None, defer: None, + body_empty: None, + }, + ), + ( + "Lunch with Tom on Thursday", + Expected { + title_contains: "Tom", status: "inbox", + project: None, area: None, + scheduled: Some("2026-03-26"), due: None, defer: None, + body_empty: None, + }, + ), + ( + "Schedule the Half Marathon Training run for next Monday", + Expected { + title_contains: "run", status: "inbox", + project: Some("p-marathon"), area: None, + scheduled: Some("2026-03-30"), due: None, defer: None, + body_empty: None, + }, + ), + + // ============================================================= + // SCHEDULED DATES — "today" / "this afternoon" + // ============================================================= + + ( + "Buy milk this afternoon", + Expected { + title_contains: "milk", status: "inbox", + project: None, area: None, + scheduled: Some("2026-03-25"), due: None, defer: None, + body_empty: None, + }, + ), + ( + "Call the bank today about that charge", + Expected { + title_contains: "bank", status: "inbox", + project: None, area: None, + scheduled: Some("2026-03-25"), due: None, defer: None, + body_empty: None, + }, + ), + + // ============================================================= + // DUE DATES — deadline language + // ============================================================= + + ( + "Submit the Q1 tax return by April 15th", + Expected { + title_contains: "tax", status: "inbox", + project: Some("p-tax"), area: None, + scheduled: None, due: Some("2026-04-15"), defer: None, + body_empty: None, + }, + ), + ( + "The report is due by Friday", + Expected { + title_contains: "report", status: "inbox", + project: None, area: None, + scheduled: None, due: Some("2026-03-27"), defer: None, + body_empty: None, + }, + ), + ( + "Book flights by the end of next week", + Expected { + title_contains: "flight", status: "inbox", + project: None, area: None, + scheduled: None, due: Some("2026-04-03"), defer: None, + body_empty: None, + }, + ), + ( + "Renew passport, deadline is June 1st", + Expected { + title_contains: "passport", status: "inbox", + project: None, area: None, + scheduled: None, due: Some("2026-06-01"), defer: None, + body_empty: None, + }, + ), + + // ============================================================= + // STATUS — icebox (someday/maybe) + // ============================================================= + + ( + "Maybe one day learn to play guitar", + Expected { + title_contains: "guitar", status: "inbox", + project: None, area: None, + scheduled: None, due: None, defer: None, + body_empty: None, + }, + ), + ( + "I might eventually look into getting a motorbike licence", + Expected { + title_contains: "motorbike", status: "inbox", + project: None, area: None, + scheduled: None, due: None, defer: None, + body_empty: None, + }, + ), + + // ============================================================= + // STATUS — blocked + // ============================================================= + + ( + "The API refactor is blocked waiting on the security review", + Expected { + title_contains: "API", status: "blocked", + project: None, area: None, + scheduled: None, due: None, defer: None, + body_empty: None, + }, + ), + ( + "Can't finish the Garden Renovation until the quote comes back", + Expected { + title_contains: "Garden", status: "inbox", // no explicit "blocked" keyword + project: Some("p-garden"), area: None, + scheduled: None, due: None, defer: None, + body_empty: None, + }, + ), + + // ============================================================= + // COMPLEX / DICTATION — multiple fields + // ============================================================= + + ( + "Email James about the Japan Trip, schedule for next Monday", + Expected { + title_contains: "James", status: "inbox", + project: Some("p-japan"), area: None, + scheduled: Some("2026-03-30"), due: None, defer: None, + body_empty: None, + }, + ), + ( + "I need to send that invoice to Acme Corp by the end of the month", + Expected { + title_contains: "invoice", status: "inbox", + project: None, area: Some("a-acme"), + scheduled: None, due: Some("2026-03-31"), defer: None, + body_empty: None, + }, + ), + ( + "Review the Newsletter Setup project tomorrow, we need to get it done before April", + Expected { + title_contains: "Newsletter", status: "inbox", + project: Some("p-newsletter"), area: None, + scheduled: Some("2026-03-26"), due: Some("2026-03-31"), defer: None, + body_empty: None, + }, + ), + + // ============================================================= + // NO HALLUCINATION — words that look like area names but aren't + // ============================================================= + + ( + "Check if my health insurance covers this procedure", + Expected { + title_contains: "insurance", status: "inbox", + project: None, area: None, + scheduled: None, due: None, defer: None, + body_empty: None, + }, + ), + ( + "Pick up something on the way home", + Expected { + title_contains: "home", status: "inbox", + project: None, area: None, + scheduled: None, due: None, defer: None, + body_empty: None, + }, + ), + ( + "I'm learning a lot from this course", + Expected { + title_contains: "course", status: "inbox", + project: None, area: None, + scheduled: None, due: None, defer: None, + body_empty: None, + }, + ), + ]; + + println!("\n======================================================================"); + println!("AI Quick Entry Evaluation — {} cases", cases.len()); + println!("Context date: {EVAL_DATE} ({EVAL_DAY})"); + println!("======================================================================\n"); + + let mut total_pass = 0; + let mut total_fail = 0; + + for (input, expected) in &cases { + let (result, failures) = run_eval(input, expected); + + if failures.is_empty() { + total_pass += 1; + println!(" ✓ {input:?}"); + } else { + total_fail += 1; + println!(" ✗ {input:?}"); + println!( + " Raw: title={:?} status={:?} project={:?} area={:?}", + result.title, result.status, result.project_id, result.area_id + ); + println!( + " due={:?} sched={:?} defer={:?}", + result.due, result.scheduled, result.defer_until + ); + for f in &failures { + println!(" FAIL: {f}"); + } + } + } + + println!("\n----------------------------------------------------------------------"); + println!( + "Results: {total_pass} passed, {total_fail} failed out of {} cases", + cases.len() + ); + println!("----------------------------------------------------------------------\n"); + + // Don't assert — this is an eval tool, not a hard test. + // Some failures are expected while iterating on prompts. + if total_fail > 0 { + println!("NOTE: {total_fail} cases failed. This is expected while iterating."); + println!(" Review failures above and adjust ai_prompts.rs as needed."); + } + } +} diff --git a/tdn-desktop/src-tauri/src/commands/ai_prompts.rs b/tdn-desktop/src-tauri/src/commands/ai_prompts.rs new file mode 100644 index 00000000..bd79b7e4 --- /dev/null +++ b/tdn-desktop/src-tauri/src/commands/ai_prompts.rs @@ -0,0 +1,117 @@ +//! AI prompt templates for Apple Intelligence quick entry processing. +//! +//! All prompt text is centralized here for easy iteration. +//! Edit this file to refine how the on-device model parses task input. + +use super::ai::NameIdPair; + +/// A project for the prompt context. +#[allow(dead_code)] // area_name reserved for potential grouped context display +pub struct ProjectWithArea { + pub name: String, + pub area_name: Option, +} + +/// Build the complete system prompt for quick entry processing. +pub fn build_system_prompt( + projects_with_areas: &[ProjectWithArea], + areas: &[NameIdPair], + today: &str, + day_of_week: &str, +) -> String { + let context_block = build_context_block(projects_with_areas, areas); + let examples_block = build_examples_block(); + + format!( + "{ROLE}\n\ + \n\ + Today is {today} ({day_of_week}).\n\ + \n\ + {context_block}\n\ + \n\ + {FIELD_DEFINITIONS}\n\ + \n\ + {examples_block}" + ) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Prompt constants +// ───────────────────────────────────────────────────────────────────────────── + +const ROLE: &str = "\ +Extract structured task fields from free-form text. \ +Return empty string for any field where the input provides no clear value. \ +Empty string is always the safe choice."; + +const FIELD_DEFINITIONS: &str = "\ +Fields: + +title: A concise, actionable task title. Rewrite the input to be short and scannable. + +body: Extra detail from the input beyond the title. Empty string if the input is simple. + +project: A project name from the projects list above. \ +Set ONLY if the input explicitly names a project. Empty string otherwise. + +area: An area name from the areas list above. \ +Set ONLY if the input explicitly names an area AND no project was matched. \ +When a project is set, leave area as empty string. + +scheduledRef: WHEN to do this task. Extract the date reference as stated: \ +'today', 'tomorrow', 'this afternoon', 'Monday', 'this Friday', 'next week'. \ +Empty string if no timing is mentioned. + +dueRef: A DEADLINE. Extract the date reference as stated: \ +'by Friday', 'April 15th', 'end of March', 'end of next week'. \ +Empty string if no deadline is mentioned. + +deferUntilRef: When this task BECOMES AVAILABLE. \ +'not until Monday', 'defer until April', 'start after next week'. \ +This is rare. Empty string unless explicitly mentioned."; + +/// Build few-shot examples. Apple guidance: <5 examples, written directly into the prompt. +/// Field order matches @Generable: title, body, project, area, scheduledRef, dueRef, deferUntilRef +fn build_examples_block() -> String { + "\ +Examples: + +Input: \"Buy groceries for the week\" +Output: {\"title\":\"Buy groceries\",\"body\":\"\",\"project\":\"\",\"area\":\"\",\"scheduledRef\":\"\",\"dueRef\":\"\",\"deferUntilRef\":\"\"} + +Input: \"Call the dentist tomorrow about that crown\" +Output: {\"title\":\"Call dentist about crown\",\"body\":\"\",\"project\":\"\",\"area\":\"\",\"scheduledRef\":\"tomorrow\",\"dueRef\":\"\",\"deferUntilRef\":\"\"} + +Input: \"Review the Garden Renovation plans with the contractor\" +Output: {\"title\":\"Review Garden Renovation plans with contractor\",\"body\":\"\",\"project\":\"Garden Renovation\",\"area\":\"\",\"scheduledRef\":\"\",\"dueRef\":\"\",\"deferUntilRef\":\"\"} + +Input: \"Submit the report by next Friday\" +Output: {\"title\":\"Submit report\",\"body\":\"\",\"project\":\"\",\"area\":\"\",\"scheduledRef\":\"\",\"dueRef\":\"next Friday\",\"deferUntilRef\":\"\"} + +Input: \"Buy milk this afternoon\" +Output: {\"title\":\"Buy milk\",\"body\":\"\",\"project\":\"\",\"area\":\"\",\"scheduledRef\":\"today\",\"dueRef\":\"\",\"deferUntilRef\":\"\"}" + .to_string() +} + +/// Build the context block with separate area and project lists. +fn build_context_block(projects_with_areas: &[ProjectWithArea], areas: &[NameIdPair]) -> String { + let area_names: Vec<&str> = areas.iter().map(|a| a.name.as_str()).collect(); + let project_names: Vec<&str> = projects_with_areas + .iter() + .map(|p| p.name.as_str()) + .collect(); + + let areas_str = if area_names.is_empty() { + "(none)".to_string() + } else { + area_names.join(", ") + }; + + let projects_str = if project_names.is_empty() { + "(none)".to_string() + } else { + project_names.join(", ") + }; + + format!("Areas: {areas_str}\nProjects: {projects_str}") +} diff --git a/tdn-desktop/src-tauri/src/commands/ai_resolve.rs b/tdn-desktop/src-tauri/src/commands/ai_resolve.rs new file mode 100644 index 00000000..caa61ff4 --- /dev/null +++ b/tdn-desktop/src-tauri/src/commands/ai_resolve.rs @@ -0,0 +1,488 @@ +//! Deterministic resolution of date expressions and fuzzy project/area matching. +//! +//! The LLM extracts raw date references ("tomorrow", "next Monday", "end of March") +//! and project/area name strings. This module resolves them to concrete values: +//! - Date expressions → YYYY-MM-DD strings via the `fuzzydate` crate +//! - Project/area names → matched IDs via case-insensitive substring matching + +use chrono::NaiveDate; + +use super::ai::{NameIdPair, ProjectContext}; + +// ============================================================================= +// Date Resolution +// ============================================================================= + +/// Resolve a natural language date expression to a YYYY-MM-DD string. +/// +/// Uses `fuzzydate::parse_relative_to` for natural language parsing, with +/// preprocessing to handle patterns fuzzydate doesn't support natively. +/// Returns None if the expression is empty or unparseable. +/// +/// Examples: +/// - "tomorrow" → "2026-03-27" (relative to 2026-03-26) +/// - "next Monday" → "2026-03-30" +/// - "April 15th" → "2026-04-15" +/// - "end of March" → "2026-03-31" +/// - "in 3 weeks" → 3 weeks from today +pub fn resolve_date_expression(expr: &str, today: NaiveDate) -> Option { + let trimmed = expr.trim(); + if trimmed.is_empty() { + return None; + } + + // If it's already a YYYY-MM-DD date (LLM might still output these), use it directly + if NaiveDate::parse_from_str(trimmed, "%Y-%m-%d").is_ok() { + return Some(trimmed.to_string()); + } + + // Preprocess: strip ordinal suffixes and "on"/"by" prefixes + let cleaned = preprocess_date_expr(trimmed); + + // Try custom handlers for patterns fuzzydate doesn't support + if let Some(date) = resolve_end_of_month(&cleaned, today) { + return Some(date.format("%Y-%m-%d").to_string()); + } + if let Some(date) = resolve_in_n_weeks(&cleaned, today) { + return Some(date.format("%Y-%m-%d").to_string()); + } + + // Use fuzzydate to parse the expression relative to today + let reference = today.and_hms_opt(12, 0, 0)?; // noon to avoid edge cases + match fuzzydate::parse_relative_to(&cleaned, reference) { + Ok(parsed) => Some(parsed.date().format("%Y-%m-%d").to_string()), + Err(_) => { + log::debug!("Could not parse date expression: {trimmed:?}"); + None + } + } +} + +/// Preprocess a date expression to handle patterns fuzzydate doesn't support: +/// - Strip ordinal suffixes: "15th" → "15", "1st" → "1", "2nd" → "2", "3rd" → "3" +/// - Strip leading "on": "on Thursday" → "Thursday" +/// - Strip leading "by": "by Friday" → "Friday" +fn preprocess_date_expr(expr: &str) -> String { + let mut s = expr.to_string(); + + // Strip leading "on " or "by " + for prefix in &["on ", "by "] { + if let Some(rest) = s.to_lowercase().strip_prefix(prefix) { + s = expr[prefix.len()..].to_string(); + let _ = rest; // suppress unused warning + } + } + + // Strip ordinal suffixes from numbers: "15th" → "15" + let ordinal_re = regex::Regex::new(r"(\d+)(st|nd|rd|th)\b").unwrap(); + s = ordinal_re.replace_all(&s, "$1").to_string(); + + s +} + +/// Handle "end of [month]" / "end of the month" expressions. +fn resolve_end_of_month(expr: &str, today: NaiveDate) -> Option { + let lower = expr.to_lowercase(); + + if lower == "end of the month" || lower == "end of month" { + // Last day of the current month + return last_day_of_month(today.year(), today.month()); + } + + // "end of March", "end of April", etc. + let months = [ + ("january", 1), + ("february", 2), + ("march", 3), + ("april", 4), + ("may", 5), + ("june", 6), + ("july", 7), + ("august", 8), + ("september", 9), + ("october", 10), + ("november", 11), + ("december", 12), + ]; + + if let Some(rest) = lower.strip_prefix("end of ") { + let rest = rest.trim(); + for (name, num) in &months { + if rest == *name { + let year = if *num < today.month() { + today.year() + 1 // month already passed → next year + } else { + today.year() + }; + return last_day_of_month(year, *num); + } + } + } + + None +} + +/// Handle "in N weeks" / "in N days" expressions. +fn resolve_in_n_weeks(expr: &str, today: NaiveDate) -> Option { + let lower = expr.to_lowercase(); + + // Word-to-number mapping for common cases + let word_to_num = |w: &str| -> Option { + match w { + "one" | "a" => Some(1), + "two" => Some(2), + "three" => Some(3), + "four" => Some(4), + "five" => Some(5), + "six" => Some(6), + "seven" => Some(7), + "eight" => Some(8), + _ => w.parse().ok(), + } + }; + + // "in N weeks" / "in N week" + if let Some(rest) = lower.strip_prefix("in ") { + let parts: Vec<&str> = rest.split_whitespace().collect(); + if parts.len() == 2 { + if let Some(n) = word_to_num(parts[0]) { + if parts[1].starts_with("week") { + return Some(today + chrono::Duration::weeks(n)); + } + if parts[1].starts_with("day") { + return Some(today + chrono::Duration::days(n)); + } + if parts[1].starts_with("month") { + // Proper calendar month arithmetic + let new_month = today.month() as i64 + n; + let year_offset = (new_month - 1) / 12; + let month = ((new_month - 1) % 12 + 1) as u32; + let year = today.year() + year_offset as i32; + // Clamp day to month end (e.g. Jan 31 + 1 month → Feb 28) + let max_day = last_day_of_month(year, month) + .map(|d| d.day()) + .unwrap_or(28); + let day = today.day().min(max_day); + return NaiveDate::from_ymd_opt(year, month, day); + } + } + } + } + + None +} + +/// Get the last day of a given month. +fn last_day_of_month(year: i32, month: u32) -> Option { + if month == 12 { + NaiveDate::from_ymd_opt(year + 1, 1, 1).and_then(|d| d.pred_opt()) + } else { + NaiveDate::from_ymd_opt(year, month + 1, 1).and_then(|d| d.pred_opt()) + } +} + +use chrono::Datelike; + +// ============================================================================= +// Fuzzy Project/Area Matching +// ============================================================================= + +/// Match a project name from AI output against the available projects. +/// Uses case-insensitive substring matching with a minimum length guard. +/// Returns the project ID if matched, None otherwise. +pub fn match_project_fuzzy(name: &str, projects: &[ProjectContext]) -> Option { + let query = name.trim(); + if query.is_empty() { + return None; + } + + // Try exact match first (case-insensitive) + if let Some(p) = projects.iter().find(|p| p.name.eq_ignore_ascii_case(query)) { + return Some(p.id.clone()); + } + + // Substring match: query is a substring of a project name, or vice versa + // Minimum 3 characters to prevent spurious short matches + if query.len() >= 3 { + let lower_query = query.to_lowercase(); + // Check if query is contained in any project name + if let Some(p) = projects + .iter() + .find(|p| p.name.to_lowercase().contains(&lower_query)) + { + return Some(p.id.clone()); + } + // Check if any project name is contained in the query + if let Some(p) = projects + .iter() + .find(|p| lower_query.contains(&p.name.to_lowercase())) + { + return Some(p.id.clone()); + } + } + + None +} + +/// Match an area name from AI output against the available areas. +/// Uses case-insensitive substring matching with a minimum length guard. +/// Returns the area ID if matched, None otherwise. +pub fn match_area_fuzzy(name: &str, areas: &[NameIdPair]) -> Option { + let query = name.trim(); + if query.is_empty() { + return None; + } + + // Try exact match first (case-insensitive) + if let Some(a) = areas.iter().find(|a| a.name.eq_ignore_ascii_case(query)) { + return Some(a.id.clone()); + } + + // Substring match with minimum length guard + if query.len() >= 3 { + let lower_query = query.to_lowercase(); + if let Some(a) = areas + .iter() + .find(|a| a.name.to_lowercase().contains(&lower_query)) + { + return Some(a.id.clone()); + } + if let Some(a) = areas + .iter() + .find(|a| lower_query.contains(&a.name.to_lowercase())) + { + return Some(a.id.clone()); + } + } + + None +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + fn test_date() -> NaiveDate { + // Wednesday, 2026-03-25 (matches eval harness) + NaiveDate::from_ymd_opt(2026, 3, 25).unwrap() + } + + // ── Date resolution tests ──────────────────────────────────────────── + + #[test] + fn date_empty_returns_none() { + assert_eq!(resolve_date_expression("", test_date()), None); + assert_eq!(resolve_date_expression(" ", test_date()), None); + } + + #[test] + fn date_today() { + assert_eq!( + resolve_date_expression("today", test_date()), + Some("2026-03-25".into()) + ); + } + + #[test] + fn date_tomorrow() { + assert_eq!( + resolve_date_expression("tomorrow", test_date()), + Some("2026-03-26".into()) + ); + } + + #[test] + fn date_this_friday() { + // Wednesday March 25 → this Friday = March 27 + assert_eq!( + resolve_date_expression("this Friday", test_date()), + Some("2026-03-27".into()) + ); + } + + #[test] + fn date_next_monday() { + // Wednesday March 25 → next Monday = March 30 + assert_eq!( + resolve_date_expression("next Monday", test_date()), + Some("2026-03-30".into()) + ); + } + + #[test] + fn date_specific_month_day() { + assert_eq!( + resolve_date_expression("April 15th", test_date()), + Some("2026-04-15".into()) + ); + } + + #[test] + fn date_end_of_march() { + assert_eq!( + resolve_date_expression("end of March", test_date()), + Some("2026-03-31".into()) + ); + } + + #[test] + fn date_passthrough_iso() { + // Already YYYY-MM-DD → pass through + assert_eq!( + resolve_date_expression("2026-06-01", test_date()), + Some("2026-06-01".into()) + ); + } + + #[test] + fn date_nonsense_returns_none() { + assert_eq!(resolve_date_expression("banana", test_date()), None); + } + + #[test] + fn date_in_one_month() { + // March 25 + 1 month = April 25 (proper calendar arithmetic, not 30 days) + assert_eq!( + resolve_date_expression("in 1 month", test_date()), + Some("2026-04-25".into()) + ); + } + + #[test] + fn date_in_month_clamps_to_month_end() { + // Jan 31 + 1 month should be Feb 28 (not March 3) + let jan31 = NaiveDate::from_ymd_opt(2026, 1, 31).unwrap(); + assert_eq!( + resolve_date_expression("in 1 month", jan31), + Some("2026-02-28".into()) + ); + } + + #[test] + fn date_by_end_of_march() { + // "by end of March" — preprocessing strips "by", then resolves "end of March" + assert_eq!( + resolve_date_expression("by end of March", test_date()), + Some("2026-03-31".into()) + ); + } + + // ── Project fuzzy matching tests ───────────────────────────────────── + + fn test_projects() -> Vec { + vec![ + ProjectContext { + id: "p-japan".into(), + name: "Japan Trip 2025".into(), + area_name: Some("Travel".into()), + }, + ProjectContext { + id: "p-cli".into(), + name: "Open Source CLI Tool".into(), + area_name: Some("Coding".into()), + }, + ProjectContext { + id: "p-blog".into(), + name: "Tech Blog Relaunch".into(), + area_name: Some("Writing".into()), + }, + ] + } + + #[test] + fn project_exact_match() { + let projects = test_projects(); + assert_eq!( + match_project_fuzzy("Japan Trip 2025", &projects), + Some("p-japan".into()) + ); + } + + #[test] + fn project_exact_case_insensitive() { + let projects = test_projects(); + assert_eq!( + match_project_fuzzy("japan trip 2025", &projects), + Some("p-japan".into()) + ); + } + + #[test] + fn project_substring_partial_name() { + let projects = test_projects(); + // "Japan Trip" is a substring of "Japan Trip 2025" + assert_eq!( + match_project_fuzzy("Japan Trip", &projects), + Some("p-japan".into()) + ); + } + + #[test] + fn project_substring_middle() { + let projects = test_projects(); + assert_eq!( + match_project_fuzzy("CLI Tool", &projects), + Some("p-cli".into()) + ); + } + + #[test] + fn project_empty_returns_none() { + let projects = test_projects(); + assert_eq!(match_project_fuzzy("", &projects), None); + } + + #[test] + fn project_no_match() { + let projects = test_projects(); + assert_eq!(match_project_fuzzy("Nonexistent Project", &projects), None); + } + + #[test] + fn project_too_short_no_match() { + let projects = test_projects(); + // "Ja" is only 2 chars — below minimum, should not match + assert_eq!(match_project_fuzzy("Ja", &projects), None); + } + + // ── Area fuzzy matching tests ──────────────────────────────────────── + + fn test_areas() -> Vec { + vec![ + NameIdPair { + id: "a-acme".into(), + name: "Acme Corp".into(), + }, + NameIdPair { + id: "a-finance".into(), + name: "Finance".into(), + }, + NameIdPair { + id: "a-home".into(), + name: "Home".into(), + }, + ] + } + + #[test] + fn area_exact_match() { + let areas = test_areas(); + assert_eq!(match_area_fuzzy("Acme Corp", &areas), Some("a-acme".into())); + } + + #[test] + fn area_substring_match() { + let areas = test_areas(); + assert_eq!(match_area_fuzzy("Acme", &areas), Some("a-acme".into())); + } + + #[test] + fn area_no_match() { + let areas = test_areas(); + assert_eq!(match_area_fuzzy("Marketing", &areas), None); + } +} diff --git a/tdn-desktop/src-tauri/src/commands/mod.rs b/tdn-desktop/src-tauri/src/commands/mod.rs index 9dc94f58..bc0bee13 100644 --- a/tdn-desktop/src-tauri/src/commands/mod.rs +++ b/tdn-desktop/src-tauri/src/commands/mod.rs @@ -3,6 +3,9 @@ //! Each submodule contains related commands and their helper functions. //! Import specific commands via their submodule (e.g., `commands::preferences::greet`). +pub mod ai; +pub(crate) mod ai_prompts; +pub(crate) mod ai_resolve; pub mod config; pub mod notifications; pub mod preferences; diff --git a/tdn-desktop/src-tauri/src/commands/vault.rs b/tdn-desktop/src-tauri/src/commands/vault.rs index 4ccaec3a..b3f5327b 100644 --- a/tdn-desktop/src-tauri/src/commands/vault.rs +++ b/tdn-desktop/src-tauri/src/commands/vault.rs @@ -978,4 +978,117 @@ updated-at: 2025-01-15 assert_eq!(manager.list_projects().unwrap().len(), 2); assert!(manager.list_areas().unwrap().is_empty()); } + + // ------------------------------------------------------------------------- + // ID-to-Title Resolution Tests + // ------------------------------------------------------------------------- + + #[test] + fn create_task_resolves_project_id_to_title() { + let temp_dir = create_test_vault(); + let manager = create_test_manager(&temp_dir); + + // Create a project first + let project = manager + .create_project(CreateProjectOptions { + title: "My Project".to_string(), + status: None, + area_id: None, + start_date: None, + end_date: None, + description: None, + }) + .unwrap(); + + // Create a task using the project's hash ID + let task = manager + .create_task(CreateTaskOptions { + title: Some("Test Task".to_string()), + project_id: Some(project.id.clone()), + ..Default::default() + }) + .unwrap(); + + // The wikilink should contain the title, not the hash ID + assert!(task.project.is_some()); + let project_ref = task.project.unwrap(); + assert!( + project_ref.contains("My Project"), + "Expected wikilink with title, got: {project_ref}" + ); + assert!( + !project_ref.contains(&project.id), + "Wikilink should not contain hash ID" + ); + } + + #[test] + fn create_task_preserves_unknown_project_id() { + let temp_dir = create_test_vault(); + let manager = create_test_manager(&temp_dir); + + // Use an ID that doesn't match any project — should be preserved as-is + let task = manager + .create_task(CreateTaskOptions { + title: Some("Test Task".to_string()), + project_id: Some("nonexistent-id".to_string()), + ..Default::default() + }) + .unwrap(); + + // The original string is preserved since it didn't resolve + assert!(task.project.is_some()); + let project_ref = task.project.unwrap(); + assert!( + project_ref.contains("nonexistent-id"), + "Unknown ID should be preserved: {project_ref}" + ); + } + + #[test] + fn update_task_resolves_project_id_to_title() { + let temp_dir = create_test_vault(); + let manager = create_test_manager(&temp_dir); + + // Create project and task + let project = manager + .create_project(CreateProjectOptions { + title: "Update Project".to_string(), + status: None, + area_id: None, + start_date: None, + end_date: None, + description: None, + }) + .unwrap(); + + let task = manager + .create_task(CreateTaskOptions { + title: Some("Task".to_string()), + ..Default::default() + }) + .unwrap(); + + // Update task with project hash ID + let updated = manager + .update_task(TaskUpdate { + id: task.id, + project: Some(project.id.clone()), + title: None, + status: None, + area: None, + scheduled: None, + due: None, + defer_until: None, + body: None, + }) + .unwrap(); + + assert!(updated.project.is_some()); + let project_ref = updated.project.unwrap(); + assert!( + project_ref.contains("Update Project"), + "Expected resolved title, got: {project_ref}" + ); + } } diff --git a/tdn-desktop/src-tauri/src/lib.rs b/tdn-desktop/src-tauri/src/lib.rs index f5f15660..4f60d201 100644 --- a/tdn-desktop/src-tauri/src/lib.rs +++ b/tdn-desktop/src-tauri/src/lib.rs @@ -10,6 +10,9 @@ mod types; mod utils; pub mod vault; +#[cfg(all(target_os = "macos", target_arch = "aarch64"))] +mod apple_intelligence; + use std::error::Error; use tauri::{App, AppHandle, Manager, RunEvent, WindowEvent}; use vault::VaultManager; diff --git a/tdn-desktop/src-tauri/src/vault/manager.rs b/tdn-desktop/src-tauri/src/vault/manager.rs index 8e9cda37..a851e68b 100644 --- a/tdn-desktop/src-tauri/src/vault/manager.rs +++ b/tdn-desktop/src-tauri/src/vault/manager.rs @@ -426,13 +426,29 @@ impl VaultManager { options.title.as_deref().unwrap_or("(untitled)") ); - let tasks_dir = { + let (tasks_dir, options) = { let inner = self.inner.read(); - inner + let tasks_dir = inner .config .as_ref() .map(|c| c.tasks_dir.clone()) - .ok_or_else(|| VaultError::not_configured("Vault not initialized"))? + .ok_or_else(|| VaultError::not_configured("Vault not initialized"))?; + + // Resolve project/area IDs to titles for wikilinks. + // The writer expects titles (e.g. "Q1 Planning"), not hash IDs. + let mut options = options; + if let Some(ref id) = options.project_id { + if let Some(project) = inner.index.get_project(id) { + options.project_id = Some(project.title.clone()); + } + } + if let Some(ref id) = options.area_id { + if let Some(area) = inner.index.get_area(id) { + options.area_id = Some(area.title.clone()); + } + } + + (tasks_dir, options) }; // Use RAII guard to ensure write flag is always reset, even on panic @@ -454,13 +470,23 @@ impl VaultManager { self.ensure_configured()?; debug!("Creating project: {}", options.title); - let projects_dir = { + let (projects_dir, options) = { let inner = self.inner.read(); - inner + let projects_dir = inner .config .as_ref() .map(|c| c.projects_dir.clone()) - .ok_or_else(|| VaultError::not_configured("Vault not initialized"))? + .ok_or_else(|| VaultError::not_configured("Vault not initialized"))?; + + // Resolve area ID to title for wikilinks + let mut options = options; + if let Some(ref id) = options.area_id { + if let Some(area) = inner.index.get_area(id) { + options.area_id = Some(area.title.clone()); + } + } + + (projects_dir, options) }; let _guard = WriteFlagGuard::new(self); @@ -482,6 +508,26 @@ impl VaultManager { let task = self.get_task(&update.id)?; + // Resolve project/area IDs to titles for wikilinks (single lock) + let mut update = update; + { + let inner = self.inner.read(); + if let Some(ref value) = update.project { + if !value.is_empty() { + if let Some(project) = inner.index.get_project(value) { + update.project = Some(project.title.clone()); + } + } + } + if let Some(ref value) = update.area { + if !value.is_empty() { + if let Some(area) = inner.index.get_area(value) { + update.area = Some(area.title.clone()); + } + } + } + } + let _guard = WriteFlagGuard::new(self); let updated_task = crate::vault::update_task(&task, update.clone())?; @@ -501,6 +547,17 @@ impl VaultManager { let project = self.get_project(&update.id)?; + // Resolve area ID to title for wikilinks + let mut update = update; + if let Some(ref value) = update.area { + if !value.is_empty() { + let inner = self.inner.read(); + if let Some(area) = inner.index.get_area(value) { + update.area = Some(area.title.clone()); + } + } + } + let _guard = WriteFlagGuard::new(self); let updated_project = crate::vault::update_project(&project, update.clone())?; diff --git a/tdn-desktop/src-tauri/swift/apple_intelligence.swift b/tdn-desktop/src-tauri/swift/apple_intelligence.swift new file mode 100644 index 00000000..f188c9ec --- /dev/null +++ b/tdn-desktop/src-tauri/swift/apple_intelligence.swift @@ -0,0 +1,178 @@ +import Dispatch +import Foundation +import FoundationModels +// MARK: - Generable types for structured task parsing + +@available(macOS 26.0, *) +@Generable +private struct ParsedTask: Sendable { + @Guide(description: "Concise task title") + let title: String + + @Guide(description: "Extra detail, or empty string") + let body: String + + @Guide(description: "Project name or empty string") + let project: String + + @Guide(description: "Area name or empty string") + let area: String + + @Guide(description: "When to do this task, e.g. 'today' or 'next Monday', or empty string") + let scheduledRef: String + + @Guide(description: "Deadline date reference, e.g. 'by Friday' or 'April 15th', or empty string") + let dueRef: String + + @Guide(description: "When task becomes available, e.g. 'after Monday', or empty string") + let deferUntilRef: String +} + +// MARK: - Helpers + +private typealias ResponsePointer = UnsafeMutablePointer + +private func duplicateCString(_ text: String) -> UnsafeMutablePointer? { + return text.withCString { basePointer in + guard let duplicated = strdup(basePointer) else { return nil } + return duplicated + } +} + +/// Strip invisible Unicode characters that LLMs sometimes insert. +private func stripInvisibleChars(_ text: String) -> String { + return text.replacingOccurrences(of: "\u{200B}", with: "") // zero-width space + .replacingOccurrences(of: "\u{200C}", with: "") // zero-width non-joiner + .replacingOccurrences(of: "\u{200D}", with: "") // zero-width joiner + .replacingOccurrences(of: "\u{FEFF}", with: "") // BOM +} + +// MARK: - Convert ParsedTask to JSON string + +@available(macOS 26.0, *) +private func parsedTaskToJSON(_ task: ParsedTask) -> String { + let dict: [String: String] = [ + "title": task.title, + "body": task.body, + "project": task.project, + "area": task.area, + "scheduledRef": task.scheduledRef, + "dueRef": task.dueRef, + "deferUntilRef": task.deferUntilRef, + ] + + guard let data = try? JSONSerialization.data(withJSONObject: dict, options: []), + let json = String(data: data, encoding: .utf8) else { + // Fallback: return minimal valid JSON on serialization failure + return "{\"title\":\"\",\"body\":\"\",\"project\":\"\",\"area\":\"\",\"scheduledRef\":\"\",\"dueRef\":\"\",\"deferUntilRef\":\"\"}" + } + return json +} + +// MARK: - Public C-callable functions + +@_cdecl("is_apple_intelligence_available") +public func isAppleIntelligenceAvailable() -> Int32 { + guard #available(macOS 26.0, *) else { + return 0 + } + + let model = SystemLanguageModel.default + switch model.availability { + case .available: + return 1 + case .unavailable: + return 0 + } +} + +@_cdecl("process_text_with_system_prompt_apple") +public func processTextWithSystemPrompt( + _ systemPrompt: UnsafePointer, + _ userContent: UnsafePointer, + _maxTokens: Int32 // unused, kept for ABI compatibility +) -> UnsafeMutablePointer { + let swiftSystemPrompt = String(cString: systemPrompt) + let swiftUserContent = String(cString: userContent) + let responsePtr = ResponsePointer.allocate(capacity: 1) + responsePtr.initialize(to: AppleLLMResponse(response: nil, success: 0, error_message: nil)) + + guard #available(macOS 26.0, *) else { + responsePtr.pointee.error_message = duplicateCString( + "Apple Intelligence requires macOS 26 or newer." + ) + return responsePtr + } + + let model = SystemLanguageModel.default + guard model.availability == .available else { + responsePtr.pointee.error_message = duplicateCString( + "Apple Intelligence is not currently available on this device." + ) + return responsePtr + } + + let semaphore = DispatchSemaphore(value: 0) + + final class ResultBox: @unchecked Sendable { + var response: String? + var error: String? + } + let box = ResultBox() + + let task = Task.detached(priority: .userInitiated) { + defer { semaphore.signal() } + do { + let session = LanguageModelSession( + model: model, + instructions: swiftSystemPrompt + ) + + // Try structured output first + do { + let structured = try await session.respond( + to: swiftUserContent, + generating: ParsedTask.self + ) + let json = parsedTaskToJSON(structured.content) + box.response = stripInvisibleChars(json) + } catch { + // Fall back to plain text response + let fallback = try await session.respond(to: swiftUserContent) + box.response = stripInvisibleChars(fallback.content) + } + } catch { + box.error = error.localizedDescription + } + } + + let timeout = semaphore.wait(timeout: .now() + 30.0) + + if timeout == .timedOut { + task.cancel() + responsePtr.pointee.error_message = duplicateCString( + "Apple Intelligence timed out after 30 seconds." + ) + } else if let response = box.response { + responsePtr.pointee.response = duplicateCString(response) + responsePtr.pointee.success = 1 + } else { + responsePtr.pointee.error_message = duplicateCString(box.error ?? "Unknown error") + } + + return responsePtr +} + +@_cdecl("free_apple_llm_response") +public func freeAppleLLMResponse(_ response: UnsafeMutablePointer?) { + guard let response = response else { return } + + if let responseStr = response.pointee.response { + free(UnsafeMutablePointer(mutating: responseStr)) + } + if let errorStr = response.pointee.error_message { + free(UnsafeMutablePointer(mutating: errorStr)) + } + + response.deallocate() +} diff --git a/tdn-desktop/src-tauri/swift/apple_intelligence_bridge.h b/tdn-desktop/src-tauri/swift/apple_intelligence_bridge.h new file mode 100644 index 00000000..dd8b407e --- /dev/null +++ b/tdn-desktop/src-tauri/swift/apple_intelligence_bridge.h @@ -0,0 +1,27 @@ +#ifndef apple_intelligence_bridge_h +#define apple_intelligence_bridge_h + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + char* response; + int success; // 0 for failure, 1 for success + char* error_message; // Only valid when success = 0 +} AppleLLMResponse; + +// Check if Apple Intelligence is available on the device +int is_apple_intelligence_available(void); + +// Process text using Apple's on-device LLM with separate system prompt and user content +AppleLLMResponse* process_text_with_system_prompt_apple(const char* system_prompt, const char* user_content, int max_tokens); + +// Free memory allocated by the Apple LLM response +void free_apple_llm_response(AppleLLMResponse* response); + +#ifdef __cplusplus +} +#endif + +#endif /* apple_intelligence_bridge_h */ diff --git a/tdn-desktop/src-tauri/swift/apple_intelligence_stub.swift b/tdn-desktop/src-tauri/swift/apple_intelligence_stub.swift new file mode 100644 index 00000000..d1b49ab6 --- /dev/null +++ b/tdn-desktop/src-tauri/swift/apple_intelligence_stub.swift @@ -0,0 +1,38 @@ +import Foundation + +// Stub implementation when FoundationModels is not available. +// Compiled via Cargo build script when the build environment +// does not support Apple Intelligence (e.g. older Xcode/SDK). + +private typealias ResponsePointer = UnsafeMutablePointer + +@_cdecl("is_apple_intelligence_available") +public func isAppleIntelligenceAvailable() -> Int32 { + return 0 +} + +@_cdecl("process_text_with_system_prompt_apple") +public func processTextWithSystemPrompt( + _ systemPrompt: UnsafePointer, + _ userContent: UnsafePointer, + _maxTokens: Int32 +) -> UnsafeMutablePointer { + let responsePtr = ResponsePointer.allocate(capacity: 1) + responsePtr.initialize(to: AppleLLMResponse(response: nil, success: 0, error_message: nil)) + responsePtr.pointee.error_message = strdup("Apple Intelligence is not available in this build (SDK requirement not met).") + return responsePtr +} + +@_cdecl("free_apple_llm_response") +public func freeAppleLLMResponse(_ response: UnsafeMutablePointer?) { + guard let response = response else { return } + + if let responseStr = response.pointee.response { + free(UnsafeMutablePointer(mutating: responseStr)) + } + if let errorStr = response.pointee.error_message { + free(UnsafeMutablePointer(mutating: errorStr)) + } + + response.deallocate() +} diff --git a/tdn-desktop/src/components/quick-pane/QuickPaneApp.tsx b/tdn-desktop/src/components/quick-pane/QuickPaneApp.tsx index a58d69ab..a3a217c1 100644 --- a/tdn-desktop/src/components/quick-pane/QuickPaneApp.tsx +++ b/tdn-desktop/src/components/quick-pane/QuickPaneApp.tsx @@ -33,6 +33,7 @@ const SHORTCUTS = { openDue: parseShortcut('Shift+CmdOrCtrl+D'), openDefer: parseShortcut('Ctrl+Shift+CmdOrCtrl+D'), openStatus: parseShortcut('CmdOrCtrl+S'), + processWithAI: parseShortcut('Shift+CmdOrCtrl+A'), } // ───────────────────────────────────────────────────────────────────────────── @@ -117,6 +118,8 @@ export default function QuickPaneApp() { const [exiting, setExiting] = React.useState(false) const [isSubmitting, setIsSubmitting] = React.useState(false) + const [isProcessingAI, setIsProcessingAI] = React.useState(false) + const [aiAvailable, setAiAvailable] = React.useState(false) const [openPopover, setOpenPopover] = React.useState(null) const [restoreFocusTo, setRestoreFocusTo] = React.useState(null) @@ -126,6 +129,7 @@ export default function QuickPaneApp() { const titleRef = React.useRef(null) const bodyRef = React.useRef(null) + const aiSessionRef = React.useRef(0) // ───────────────────────────────────────────────────────────────────────── // Reset Form @@ -145,6 +149,21 @@ export default function QuickPaneApp() { setRestoreFocusTo(null) }, []) + // ───────────────────────────────────────────────────────────────────────── + // Auto-Ready: promote inbox → ready when task appears "processed" + // A task with (project or area) AND (scheduled or defer-until) has enough + // context that it doesn't need to sit in the inbox for manual processing. + // ───────────────────────────────────────────────────────────────────────── + + React.useEffect(() => { + const hasProjectOrArea = projectId !== null || areaId !== null + const hasScheduleOrDefer = scheduled !== null || deferUntil !== null + + if (hasProjectOrArea && hasScheduleOrDefer) { + setStatus(prev => (prev === 'inbox' ? 'ready' : prev)) + } + }, [projectId, areaId, scheduled, deferUntil]) + // ───────────────────────────────────────────────────────────────────────── // Dismiss with Animation // ───────────────────────────────────────────────────────────────────────── @@ -234,6 +253,92 @@ export default function QuickPaneApp() { handleDismiss, ]) + // ───────────────────────────────────────────────────────────────────────── + // AI Processing Handler + // ───────────────────────────────────────────────────────────────────────── + + const handleProcessWithAI = async () => { + const trimmedTitle = title.trim() + if (!trimmedTitle || isProcessingAI) return + + // Session token to ignore late completions (e.g. if pane was dismissed and reopened) + const sessionId = ++aiSessionRef.current + setIsProcessingAI(true) + + try { + // Build context with project→area relationships + const stripWikilink = (s: string) => + s.startsWith('[[') && s.endsWith(']]') ? s.slice(2, -2) : s + const projectContexts = projects.map(p => ({ + id: p.id, + name: p.title, + areaName: p.area ? stripWikilink(p.area) : null, + })) + const areaPairs = areas.map(a => ({ id: a.id, name: a.title })) + + const result = await commands.processQuickEntryText( + trimmedTitle, + projectContexts, + areaPairs + ) + + // Ignore late result if a new session started or pane was reset + if (aiSessionRef.current !== sessionId) return + + if (result.status === 'error') { + logger.warn('AI processing failed', { error: result.error }) + setIsProcessingAI(false) + return + } + + const parsed = result.data + + // Populate ALL form fields from AI result (clear fields not set by AI) + setTitle(parsed.title) + setBody(parsed.body || '') + setShowBody(!!parsed.body) + setDue(parsed.due ?? null) + setScheduled(parsed.scheduled ?? null) + setDeferUntil(parsed.deferUntil ?? null) + setProjectId(parsed.projectId ?? null) + setAreaId(parsed.areaId ?? null) + + // Set status from keyword detection (Rust handles this, not the LLM) + const validStatuses: TaskStatus[] = [ + 'inbox', + 'icebox', + 'ready', + 'in-progress', + 'blocked', + ] + if (validStatuses.includes(parsed.status as TaskStatus)) { + setStatus(parsed.status as TaskStatus) + } + + // Auto-ready Rule 2 (AI only): if scheduled within 7 days and status + // is still inbox (keyword detection didn't override), promote to ready. + // Compare date strings (YYYY-MM-DD) to avoid time-of-day issues. + if (parsed.status === 'inbox' && parsed.scheduled) { + const todayStr = getTodayISO() + const todayDate = new Date(todayStr + 'T00:00:00') + const scheduledDate = new Date(parsed.scheduled + 'T00:00:00') + const daysUntil = Math.round( + (scheduledDate.getTime() - todayDate.getTime()) / + (1000 * 60 * 60 * 24) + ) + if (daysUntil >= 0 && daysUntil <= 7) { + setStatus('ready') + } + } + + logger.info('AI processing complete') + } catch (error) { + logger.error('Unexpected error during AI processing', { error }) + } + + setIsProcessingAI(false) + } + // ───────────────────────────────────────────────────────────────────────── // Theme Sync // ───────────────────────────────────────────────────────────────────────── @@ -265,7 +370,7 @@ export default function QuickPaneApp() { // Reset form on focus (fresh start) resetForm() - // Load areas and projects + // Load areas and projects (required for the pane to work) const [areasResult, projectsResult] = await Promise.all([ commands.listAreas(), commands.listProjects(), @@ -278,6 +383,13 @@ export default function QuickPaneApp() { setProjects(projectsResult.data) } + // Check AI availability separately so failures don't block init + try { + setAiAvailable(await commands.checkAppleIntelligenceAvailable()) + } catch { + setAiAvailable(false) + } + // Focus title input setTimeout(() => titleRef.current?.focus(), FOCUS_DELAY_MS) } else { @@ -339,6 +451,7 @@ export default function QuickPaneApp() { setOpenPopover(popover) }, onClosePopover: () => setOpenPopover(null), + onProcessWithAI: aiAvailable ? handleProcessWithAI : undefined, captureCurrentFocus, openPopover, showBody, @@ -373,6 +486,9 @@ export default function QuickPaneApp() { onChange={setTitle} onKeyDown={handleTitleKeyDown} inputRef={titleRef} + aiAvailable={aiAvailable} + aiProcessing={isProcessingAI} + onProcessWithAI={handleProcessWithAI} /> void onKeyDown?: (e: React.KeyboardEvent) => void inputRef?: React.RefObject + aiAvailable?: boolean + aiProcessing?: boolean + onProcessWithAI?: () => void } /** - * QuickPaneTitle - Title input row with visual checkbox. + * QuickPaneTitle - Title input row with visual checkbox and optional AI button. * * Features: * - Visual-only checkbox (always unchecked, non-interactive) * - Auto-resizing textarea that grows with content * - Prevents Enter from creating newlines (handled by parent) + * - AI processing button (visible only when Apple Intelligence is available) */ export function QuickPaneTitle({ value, onChange, onKeyDown, inputRef, + aiAvailable, + aiProcessing, + onProcessWithAI, }: QuickPaneTitleProps) { const handleChange = (e: React.ChangeEvent) => { onChange(e.target.value) @@ -29,6 +36,8 @@ export function QuickPaneTitle({ e.target.style.height = `${e.target.scrollHeight}px` } + const showAIButton = aiAvailable && value.trim().length > 0 + return (
{/* Visual checkbox - vertically centered with first line of text-xl textarea */} @@ -47,6 +56,60 @@ export function QuickPaneTitle({ autoCapitalize="off" spellCheck={false} /> + + {showAIButton && ( + + )}
) } + +/** Sparkles icon for the AI button */ +function SparklesIcon({ className }: { className?: string }) { + return ( + + + + + + ) +} + +/** Simple spinner icon for loading state */ +function SpinnerIcon({ className }: { className?: string }) { + return ( + + + + ) +} diff --git a/tdn-desktop/src/components/quick-pane/useQuickPaneKeyboard.ts b/tdn-desktop/src/components/quick-pane/useQuickPaneKeyboard.ts index 72e2fb50..f22c92a6 100644 --- a/tdn-desktop/src/components/quick-pane/useQuickPaneKeyboard.ts +++ b/tdn-desktop/src/components/quick-pane/useQuickPaneKeyboard.ts @@ -16,6 +16,8 @@ interface UseQuickPaneKeyboardOptions { onOpenPopover: (popover: PopoverType) => void /** Called when Escape pressed with a popover open */ onClosePopover: () => void + /** Called when Cmd+Shift+A pressed (only if AI is available) */ + onProcessWithAI?: () => void /** Called before opening a popover to capture current focus */ captureCurrentFocus: () => void /** Current open popover (null if none) */ @@ -29,6 +31,7 @@ interface UseQuickPaneKeyboardOptions { openDue: ParsedShortcut openDefer: ParsedShortcut openStatus: ParsedShortcut + processWithAI: ParsedShortcut } } @@ -43,6 +46,7 @@ export function useQuickPaneKeyboard({ onSetScheduledToday, onOpenPopover, onClosePopover, + onProcessWithAI, captureCurrentFocus, openPopover, showBody, @@ -102,6 +106,13 @@ export function useQuickPaneKeyboard({ return } + // Cmd+Shift+A - process with AI (only when available) + if (onProcessWithAI && matchesKeyboardEvent(shortcuts.processWithAI, e)) { + e.preventDefault() + onProcessWithAI() + return + } + // Cmd+Shift+Enter - toggle body if (e.key === 'Enter' && e.metaKey && e.shiftKey) { e.preventDefault() @@ -127,6 +138,7 @@ export function useQuickPaneKeyboard({ onSetScheduledToday, onOpenPopover, onClosePopover, + onProcessWithAI, captureCurrentFocus, openPopover, showBody, diff --git a/tdn-desktop/src/lib/bindings.ts b/tdn-desktop/src/lib/bindings.ts index 7a8ec139..b6db00b1 100644 --- a/tdn-desktop/src/lib/bindings.ts +++ b/tdn-desktop/src/lib/bindings.ts @@ -353,6 +353,29 @@ async getEntityRawContent(entityType: string, id: string) : Promise { + return await TAURI_INVOKE("check_apple_intelligence_available"); +}, +/** + * Process free-form text input using Apple Intelligence to extract structured task fields. + * + * Takes the raw text from the quick entry title field, plus lists of available + * projects (with area relationships) and areas for context. + * + * This command is async to avoid blocking the main thread — the Swift FFI call + * uses a DispatchSemaphore which blocks for 2-3 seconds during inference. + */ +async processQuickEntryText(text: string, projects: ProjectContext[], areas: NameIdPair[]) : Promise> { + try { + return { status: "ok", data: await TAURI_INVOKE("process_quick_entry_text", { text, projects, areas }) }; +} catch (e) { + if(e instanceof Error) throw e; + else return { status: "error", error: e as any }; +} } } @@ -483,6 +506,22 @@ export type CreateTaskOptions = { title: string | null; status: TaskStatus | nul */ export type DummyVaultPaths = { tasksDir: string; areasDir: string; projectsDir: string } export type JsonValue = null | boolean | number | string | JsonValue[] | Partial<{ [key in string]: JsonValue }> +/** + * A name+ID pair for passing project/area context to the AI processor. + */ +export type NameIdPair = { id: string; name: string } +/** + * Result of AI-processing free-form text into structured task fields. + */ +export type ParsedQuickEntry = { title: string; body: string; status: string; due: string | null; scheduled: string | null; deferUntil: string | null; +/** + * Matched project ID (if a project name was recognised) + */ +projectId: string | null; +/** + * Matched area ID (if an area name was recognised) + */ +areaId: string | null } /** * Public project struct exposed to TypeScript via tauri-specta. */ @@ -527,6 +566,14 @@ blockedBy: string[] | null; * Markdown body content (after frontmatter) */ body: string } +/** + * A project with its area relationship for richer AI context. + */ +export type ProjectContext = { id: string; name: string; +/** + * The area name this project belongs to (if any) + */ +areaName: string | null } /** * Project status enum matching S1 spec Section 4.5 */ diff --git a/website/src/content/docs/desktop/quick-entry-pane.mdx b/website/src/content/docs/desktop/quick-entry-pane.mdx index 5aded49b..01e456ed 100644 --- a/website/src/content/docs/desktop/quick-entry-pane.mdx +++ b/website/src/content/docs/desktop/quick-entry-pane.mdx @@ -36,3 +36,17 @@ While the majority of tasks created this way probably won't have a body, pressin
Quick entry pane with body expanded
+ +## Auto-ready status + +When you set a project or area **and** a scheduled or defer-until date, the status automatically changes from `inbox` to `ready`. A task with both a project/area and a date has been processed enough that it doesn't need to sit in the inbox. + +## AI processing (macOS) + +On Macs with Apple Intelligence enabled, a sparkle button appears next to the title when you've typed something. Clicking it (or pressing ) uses on-device AI to clean up your text into a concise task title and fill in the appropriate project, area, dates and other fields based on what you typed or dictated. + +This works entirely on-device — no data is sent to any server. The AI pre-fills the form fields for you to review and adjust before saving. + + diff --git a/website/src/content/docs/reference/desktop-reference/keyboard-shortcuts.mdx b/website/src/content/docs/reference/desktop-reference/keyboard-shortcuts.mdx index 20cd2b62..80d7440b 100644 --- a/website/src/content/docs/reference/desktop-reference/keyboard-shortcuts.mdx +++ b/website/src/content/docs/reference/desktop-reference/keyboard-shortcuts.mdx @@ -106,6 +106,7 @@ These shortcuts work when the [Quick Entry Pane](/desktop/quick-entry-pane/) is | | Open due date picker | | | Open defer date picker | | | Open status picker | +| | Process with AI (macOS only, requires Apple Intelligence) | | | Close pane (or close open picker) | ## Keyboard Navigation