Remove CalDAV todo tools; overhaul quick-capture

- Remove all 6 CalDAV todo tools (create/list/update/complete/delete/search_todos)
  from tools.py definitions, imports, execute_tool branches, intent routing rules,
  generation_task labels/actions, and llm.py system prompt hints. CalDAV event
  tools remain. Todo functions still exist in caldav.py but are no longer exposed.

- Quick-capture now uses a dedicated classify_capture_intent() with a focused
  _CAPTURE_SYSTEM_PROMPT that always routes to a tool (never null). Tool set
  expanded: create_note/task/event + update_note + research_topic.

- research_topic in quick-capture calls run_research_pipeline() directly (no SSE
  buffer). run_research_pipeline() now accepts buf=None; all buf.append_event
  calls are guarded so status events are skipped when no buffer is provided.

- Fallback note now always sets body=text (was empty for texts ≤80 chars).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 19:13:07 -05:00
parent 4c77eab84b
commit 6f5170854b
7 changed files with 122 additions and 318 deletions
+32 -15
View File
@@ -11,17 +11,16 @@ from quart import Blueprint, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.config import Config
from fabledassistant.services.intent import classify_intent
from fabledassistant.services.intent import classify_capture_intent
from fabledassistant.services.tools import execute_tool, get_tools_for_user
logger = logging.getLogger(__name__)
quick_capture_bp = Blueprint("quick_capture", __name__, url_prefix="/api/quick-capture")
# Only creation tools are offered to the intent classifier here — no delete,
# update, search, or research. This keeps the fallback safe (worst case we
# create a plain note) and avoids accidental destructive actions.
_CAPTURE_TOOL_NAMES = {"create_note", "create_task", "create_event", "create_todo"}
# Tools offered to the quick-capture classifier. Excludes destructive ops
# (delete_*) and read-only queries — worst-case fallback is a plain note.
_CAPTURE_TOOL_NAMES = {"create_note", "create_task", "create_event", "update_note", "research_topic"}
@quick_capture_bp.route("", methods=["POST"])
@@ -42,21 +41,43 @@ async def quick_capture_route():
t for t in all_tools if t.get("function", {}).get("name") in _CAPTURE_TOOL_NAMES
]
intent = await classify_intent(text, capture_tools, intent_model)
intent = await classify_capture_intent(text, capture_tools, intent_model)
if intent.should_execute:
# research_topic bypasses execute_tool — run the pipeline directly
if intent.tool_name == "research_topic" and Config.searxng_enabled():
from fabledassistant.services.research import run_research_pipeline
topic = intent.arguments.get("topic", text)
model = Config.OLLAMA_MODEL
try:
note = await run_research_pipeline(topic, uid, model, intent_model)
logger.info(
"Quick-capture uid=%d: research note id=%d '%s'",
uid, note.id, note.title,
)
return jsonify({
"success": True,
"type": "note",
"message": f"Research note created: {note.title}",
"data": {"id": note.id, "title": note.title},
})
except Exception as exc:
logger.exception("Quick-capture research failed for topic: %s", topic)
return jsonify({"error": f"Research failed: {exc}"}), 500
result = await execute_tool(uid, intent.tool_name, intent.arguments)
if result.get("success"):
item_type = result.get("type", "note")
title = (result.get("data") or {}).get("title", "")
logger.info(
"Quick-capture uid=%d: created %s '%s' via intent '%s'",
"Quick-capture uid=%d: %s '%s' via intent '%s'",
uid, item_type, title, intent.tool_name,
)
return jsonify({
"success": True,
"type": item_type,
"message": f"{item_type.capitalize()} created: {title}",
"message": f"{item_type.capitalize()}: {title}",
"data": result.get("data"),
})
logger.warning(
@@ -66,13 +87,9 @@ async def quick_capture_route():
# Fall through to plain-note fallback
# Fallback: create a plain note with the raw text as the body.
# Use the first 80 chars as the title so short snippets look clean.
if len(text) <= 80:
fallback_title = text
fallback_body = ""
else:
fallback_title = text[:77] + "..."
fallback_body = text
# Always preserve the full text as the body; derive a short title.
fallback_title = text if len(text) <= 80 else text[:77] + "..."
fallback_body = text
result = await execute_tool(
uid, "create_note", {"title": fallback_title, "body": fallback_body}
@@ -54,12 +54,6 @@ _TOOL_LABELS: dict[str, str] = {
"update_event": "Updating calendar event",
"delete_event": "Removing calendar event",
"list_calendars": "Listing calendars",
"create_todo": "Creating todo",
"list_todos": "Listing todos",
"search_todos": "Searching todos",
"update_todo": "Updating todo",
"complete_todo": "Completing todo",
"delete_todo": "Removing todo",
"search_web": "Searching the web",
"research_topic": "Researching topic",
}
@@ -68,7 +62,6 @@ _TOOL_LABELS: dict[str, str] = {
_WRITE_TOOLS: frozenset[str] = frozenset({
"create_task", "create_note", "update_note", "delete_note", "delete_task",
"create_event", "update_event", "delete_event",
"create_todo", "update_todo", "complete_todo", "delete_todo",
})
# Action phrases used in the acknowledgment prompt — "You are about to: {action}"
@@ -82,18 +75,12 @@ _TOOL_ACTIONS: dict[str, str] = {
"list_notes": "list notes",
"list_tasks": "look up tasks",
"search_notes": "search through notes",
"search_todos": "search calendar todos",
"create_event": "schedule a calendar event",
"list_events": "check the calendar",
"search_events": "search calendar events",
"update_event": "update a calendar event",
"delete_event": "remove a calendar event",
"list_calendars": "list available calendars",
"create_todo": "create a calendar todo",
"list_todos": "check calendar todos",
"update_todo": "update a calendar todo",
"complete_todo": "mark a todo complete",
"delete_todo": "remove a calendar todo",
}
+71 -6
View File
@@ -84,15 +84,9 @@ Rules:
- "what are my overdue tasks", "show overdue", "tasks due today", "high priority tasks", "in progress tasks", "what's due this week" → use list_tasks with appropriate status/priority/due_before/due_after filters. For overdue, set due_before to today's date.
- If a note was created earlier in the conversation and the user provides more content for it, use update_note (not create_note).
- Use create_note ONLY when the user explicitly wants a brand new note that doesn't already exist.
- "update", "change", "rename", "set due date on" a CalDAV todo → use update_todo.
- "update", "change", "move", "reschedule" an event → use update_event.
- "delete", "cancel", "remove" an event → use delete_event.
- "which calendars", "list calendars", "my calendars" → use list_calendars.
- "create a calendar todo", "add a CalDAV todo" → use create_todo. Default to create_task for general tasks unless the user explicitly mentions CalDAV or calendar todo.
- "show calendar todos", "list calendar todos" → use list_todos.
- "find calendar todo", "search calendar todos", "find todo named X" → use search_todos with query=<keyword>.
- "completed/finished calendar todo" → use complete_todo.
- "delete/remove calendar todo" → use delete_todo.
- "remind me X minutes/hours before" an event → convert to reminder_minutes parameter on create_event.
- When the user asks about events in a time period (e.g. "events in September", "what's on next week", "my schedule for March"), use list_events with date_from/date_to covering that period. Do NOT use search_events for time-based queries — search_events is only for keyword matching against event titles.
- "delete", "remove", "trash", "get rid of" a note (not a task) → use delete_note with query=<note name/keyword>. NEVER use this for tasks.
@@ -279,3 +273,74 @@ def _try_json(text: str) -> dict | list | None:
return json.loads(text)
except (json.JSONDecodeError, TypeError):
return None
# ── Quick-capture classifier ──────────────────────────────────────────────────
# A stripped-down prompt designed for the /api/quick-capture endpoint.
# Unlike the general intent prompt, this ALWAYS routes to a create tool —
# null is not a valid response.
_CAPTURE_SYSTEM_PROMPT = """\
You are a quick-capture classifier. The user has sent a short snippet of text \
from a mobile app or external client. Classify it as a note, task, or calendar \
event, then extract the relevant fields.
Today's date is {today}.
Available tools:
{tool_summary}
Rules:
- You MUST choose one of the available tools. Never return null.
- create_task: action items, todos, reminders, things to do ("buy milk", "call John", "fix the bug", "remind me to…")
- create_event: appointments, meetings, scheduled occurrences with a date/time ("dentist Friday 2pm", "team meeting next Tuesday")
- update_note: updating, editing, appending to an existing note or task ("add to my shopping list: eggs", "mark buy milk done", "append to my meeting notes", "update my project note")
- research_topic: user wants a comprehensive research note from web sources ("research X", "look up X and make a note", "find everything about X", "compile a note on X")
- create_note: everything else — ideas, observations, links, excerpts, longer text
- For create_task / create_event: extract a concise title; put any extra detail in "body"
- For create_note: use a short descriptive title (≤60 chars); put the FULL original text as "body"
- For update_note: set "query" to the note or task title to find; set other fields as needed
- For research_topic: set "topic" to the subject being researched
- For dates use YYYY-MM-DD; for datetime use ISO 8601
- confidence: "high" if the type is clear; "medium" if you're guessing
Respond with ONLY a JSON object:
{{"tool": "tool_name", "arguments": {{...}}, "confidence": "high"|"medium"}}
Do NOT wrap in markdown code fences."""
async def classify_capture_intent(
text: str,
tools: list[dict],
model: str,
) -> IntentResult:
"""Classify quick-capture text and extract arguments.
Uses a simplified prompt that always routes to a create tool — never null.
Returns IntentResult with tool_name set. Falls back to IntentResult() only
on LLM/parse failure (caller should handle that case).
"""
if not tools:
return IntentResult()
tool_summary = _build_tool_summary(tools)
today = date_type.today().isoformat()
messages = [
{
"role": "system",
"content": _CAPTURE_SYSTEM_PROMPT.format(
today=today, tool_summary=tool_summary
),
},
{"role": "user", "content": text},
]
try:
raw = await generate_completion(messages, model, max_tokens=300, num_ctx=2048)
except Exception:
logger.warning("Quick-capture intent LLM call failed", exc_info=True)
return IntentResult()
return _parse_intent(raw, tools)
+1 -4
View File
@@ -389,13 +389,10 @@ async def build_context(
if has_caldav:
tool_lines[-1] = (
"Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes, "
"create_event, list_events, search_events, update_event, delete_event, "
"list_calendars, create_todo, list_todos, search_todos, update_todo, complete_todo, delete_todo."
"create_event, list_events, search_events, update_event, delete_event, list_calendars."
)
tool_lines.append("For calendar events, use ISO 8601 datetime format (e.g. 2026-09-30T00:00:00).")
tool_lines.append("CalDAV todos are separate from app tasks. Use create_task for app tasks, create_todo for CalDAV todos.")
tool_lines.append("When the user says 'remind me' with a time before an event, use the reminder_minutes parameter.")
tool_lines.append("Use search_todos to find a specific CalDAV todo by keyword when list_todos would return too many results.")
tool_lines.append("For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format.")
tool_lines.append("When creating notes, use the `tags` parameter — do not embed #tag text in the note body.")
tool_lines.append(
+12 -7
View File
@@ -26,15 +26,16 @@ async def run_research_pipeline(
user_id: int,
model: str,
intent_model: str,
buf,
buf=None,
) -> Note:
"""Full research pipeline: search → fetch → synthesize → create note.
Emits status events via buf.append_event throughout.
Emits status events via buf.append_event throughout (when buf is provided).
Returns the created Note.
"""
# Step 1: Generate sub-queries
buf.append_event("status", {"status": "Generating search queries..."})
if buf is not None:
buf.append_event("status", {"status": "Generating search queries..."})
queries = await _generate_sub_queries(topic, intent_model)
logger.info("Research: generated %d sub-queries for topic '%s'", len(queries), topic)
@@ -42,7 +43,8 @@ async def run_research_pipeline(
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
if i > 0:
await asyncio.sleep(0.2 * i)
buf.append_event("status", {"status": f"Searching: {query}..."})
if buf is not None:
buf.append_event("status", {"status": f"Searching: {query}..."})
results = await _search_searxng(query)
logger.info("Research: query '%s'%d results", query, len(results))
return query, results
@@ -64,7 +66,8 @@ async def run_research_pipeline(
# Fetch all unique URLs in parallel
async def _fetch_source(url: str, result: dict, query: str) -> dict:
title = result.get("title", url)
buf.append_event("status", {"status": f"Reading: {title[:60]}..."})
if buf is not None:
buf.append_event("status", {"status": f"Reading: {title[:60]}..."})
content = await fetch_url_content(url)
return {
"url": url,
@@ -98,11 +101,13 @@ async def run_research_pipeline(
)
# Step 4: Synthesize (streams tokens into chat as the note is being written)
buf.append_event("status", {"status": f"Synthesizing report from {len(synthesis_sources)} sources..."})
if buf is not None:
buf.append_event("status", {"status": f"Synthesizing report from {len(synthesis_sources)} sources..."})
title, body = await _synthesize_note(topic, synthesis_sources, model, buf)
# Step 5: Create note
buf.append_event("status", {"status": "Saving note..."})
if buf is not None:
buf.append_event("status", {"status": "Saving note..."})
note = await create_note(
user_id=user_id,
title=title,
-267
View File
@@ -5,19 +5,13 @@ import logging
from datetime import date, datetime
from fabledassistant.services.caldav import (
complete_todo,
create_event,
create_todo,
delete_event,
delete_todo,
is_caldav_configured,
list_calendars,
list_events,
list_todos,
search_events,
search_todos,
update_event,
update_todo,
)
from fabledassistant.config import Config
from fabledassistant.services.notes import create_note, delete_note, get_note_by_title, list_notes, update_note
@@ -489,178 +483,6 @@ _CALDAV_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "create_todo",
"description": "Create a CalDAV todo item on the calendar server. Use this when the user explicitly asks to create a calendar todo or CalDAV todo (NOT for regular app tasks).",
"parameters": {
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "The todo summary/title",
},
"due": {
"type": "string",
"description": "Optional due date/datetime in ISO 8601 format",
},
"description": {
"type": "string",
"description": "Optional description",
},
"priority": {
"type": "integer",
"description": "Optional priority (1=highest, 9=lowest)",
},
"reminder_minutes": {
"type": "integer",
"description": "Optional reminder N minutes before due date",
},
"timezone": {
"type": "string",
"description": "Optional IANA timezone (e.g. 'America/New_York')",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name",
},
},
"required": ["summary"],
},
},
},
{
"type": "function",
"function": {
"name": "list_todos",
"description": "List CalDAV todos from the calendar server. Use this when the user asks to see their calendar todos.",
"parameters": {
"type": "object",
"properties": {
"include_completed": {
"type": "boolean",
"description": "Whether to include completed todos (default false)",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name",
},
},
},
},
},
{
"type": "function",
"function": {
"name": "update_todo",
"description": "Update a CalDAV todo's summary, due date, description, or priority.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search term to find the todo to update (matches against summary)",
},
"summary": {
"type": "string",
"description": "New summary/title for the todo",
},
"due": {
"type": "string",
"description": "New due date or datetime in ISO 8601 format",
},
"description": {
"type": "string",
"description": "New description",
},
"priority": {
"type": "integer",
"description": "New priority (1=highest, 9=lowest)",
},
"timezone": {
"type": "string",
"description": "Optional IANA timezone for due datetime",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name to search in",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "complete_todo",
"description": "Mark a CalDAV todo as completed. Use this when the user says they finished or completed a calendar todo.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search term to find the todo to complete (matches against summary)",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "delete_todo",
"description": "Delete a CalDAV todo. Use this when the user asks to remove or delete a calendar todo.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search term to find the todo to delete (matches against summary)",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "search_todos",
"description": (
"Search CalDAV todos by keyword. Use this when the user asks to find a specific calendar todo "
"by name or content, rather than listing all todos."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Keyword to search for in todo summary or description",
},
"include_completed": {
"type": "boolean",
"description": "Include completed todos in search (default false)",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name to restrict search",
},
},
"required": ["query"],
},
},
},
]
@@ -1040,95 +862,6 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
},
}
elif tool_name == "create_todo":
result = await create_todo(
user_id=user_id,
summary=arguments.get("summary", "Untitled Todo"),
due=arguments.get("due"),
description=arguments.get("description"),
priority=arguments.get("priority"),
reminder_minutes=arguments.get("reminder_minutes"),
timezone=arguments.get("timezone"),
calendar_name=arguments.get("calendar_name"),
)
return {
"success": True,
"type": "todo",
"data": result,
}
elif tool_name == "list_todos":
todos = await list_todos(
user_id=user_id,
include_completed=arguments.get("include_completed", False),
calendar_name=arguments.get("calendar_name"),
)
return {
"success": True,
"type": "todos",
"data": {
"count": len(todos),
"todos": todos,
},
}
elif tool_name == "update_todo":
result = await update_todo(
user_id=user_id,
query=arguments["query"],
summary=arguments.get("summary"),
due=arguments.get("due"),
description=arguments.get("description"),
priority=arguments.get("priority"),
timezone=arguments.get("timezone"),
calendar_name=arguments.get("calendar_name"),
)
return {
"success": True,
"type": "todo_updated",
"data": result,
}
elif tool_name == "complete_todo":
result = await complete_todo(
user_id=user_id,
query=arguments["query"],
calendar_name=arguments.get("calendar_name"),
)
return {
"success": True,
"type": "todo_completed",
"data": result,
}
elif tool_name == "delete_todo":
result = await delete_todo(
user_id=user_id,
query=arguments["query"],
calendar_name=arguments.get("calendar_name"),
)
return {
"success": True,
"type": "todo_deleted",
"data": result,
}
elif tool_name == "search_todos":
results = await search_todos(
user_id=user_id,
query=arguments.get("query", ""),
include_completed=bool(arguments.get("include_completed", False)),
calendar_name=arguments.get("calendar_name"),
)
return {
"success": True,
"type": "todos",
"data": {
"count": len(results),
"todos": results,
},
}
elif tool_name == "get_note":
query = arguments.get("query", "")
note = await get_note_by_title(user_id, query)
+6 -6
View File
@@ -12,7 +12,7 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-03-01 — Image search with local cache (Phase 23); suggested notes improvements (limit 8, threshold 0.45, relevance scores); suggested notes UX; quick-capture endpoint
2026-03-01 — Quick-capture overhaul (dedicated classifier, research + update_note support); CalDAV todo tools removed; run_research_pipeline buf optional
## Project Overview
Fabled Assistant is a self-hosted note-taking and task-tracking application with
@@ -278,7 +278,7 @@ fabledassistant/
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming (all @login_required)
│ │ ├── images.py # /api/images/<id> GET — serve locally-cached images (no auth required; IDs are opaque)
│ │ ├── notes.py # /api/notes CRUD + wikilinks + backlinks + tag suggestions (all @login_required)
│ │ ├── quick_capture.py # /api/quick-capture POST — mobile/external single-shot item creation (session auth)
│ │ ├── quick_capture.py # /api/quick-capture POST — mobile/external single-shot item creation (session auth); uses classify_capture_intent(); supports create_note/task/event, update_note, research_topic; fallback always preserves full text as note body
│ │ ├── tasks.py # /api/tasks CRUD + PATCH status (all @login_required)
│ │ └── settings.py # /api/settings GET/PUT, GET /models — per-user settings + installed model list (@login_required)
│ ├── services/
@@ -290,12 +290,12 @@ fabledassistant/
│ │ ├── chat.py # Conversation CRUD with user_id isolation, add_message, save/summarize as note (LLM-titled, chat-tagged)
│ │ ├── generation_buffer.py # In-memory SSE event buffer with cancel_event, reconnect support, auto-cleanup; supports chat (int keys) and assist (string keys)
│ │ ├── generation_task.py # Background asyncio tasks: run_generation (chat, DB flush, titles, intent-first pipeline + tool loop) + run_assist_generation (lightweight, no DB); _INTENT_TRIGGER_WORDS + _should_skip_intent() for skipping intent on conversational messages
│ │ ├── intent.py # Intent routing: classify_intent() makes fast non-streaming LLM call (num_ctx=4096); IntentResult has ack field (one-sentence acknowledgment streamed as TTFT)
│ │ ├── intent.py # Intent routing: classify_intent() makes fast non-streaming LLM call (num_ctx=4096); IntentResult has ack field; classify_capture_intent() uses dedicated _CAPTURE_SYSTEM_PROMPT (always routes to a tool, never null)
│ │ ├── images.py # Image cache service: fetch_and_store_image (SHA-256 dedup, content-type validation, 5MB cap), get_image_record, get_image_path
│ │ ├── tools.py # LLM tool definitions (create/delete note+task, update_note w/tag management, get_note, list_notes, search_notes w/type filter, list_tasks, full CalDAV suite incl. search_todos, search_web, research_topic, search_images when SearXNG enabled) + execute_tool dispatcher
│ │ ├── research.py # SearXNG research pipeline: parallel query/fetch, streaming synthesis; run_research_pipeline() → Note; _search_searxng_images() for image category search; constants SEARXNG_QUERIES=5, PAGES_PER_QUERY=3, MAX_SYNTHESIS_SOURCES=12, CHARS_PER_SOURCE=2000
│ │ ├── tools.py # LLM tool definitions (create/delete note+task, update_note w/tag management, get_note, list_notes, search_notes w/type filter, list_tasks, CalDAV event suite (no todos), search_web, research_topic, search_images when SearXNG enabled) + execute_tool dispatcher
│ │ ├── research.py # SearXNG research pipeline: parallel query/fetch, streaming synthesis; run_research_pipeline(buf=None) → Note (buf optional — no status events when omitted); _search_searxng_images() for image category search; constants SEARXNG_QUERIES=5, PAGES_PER_QUERY=3, MAX_SYNTHESIS_SOURCES=12, CHARS_PER_SOURCE=2000
│ │ ├── tag_suggestions.py # LLM-powered tag suggestions: suggest_tags() builds prompt with existing tags, calls generate_completion, parses JSON response
│ │ ├── caldav.py # CalDAV integration: full event lifecycle (create/list/search/update/delete), todos (create/list/search/update/complete/delete), list_calendars, timezone (ZoneInfo), reminders (VALARM), attendees, multi-calendar search
│ │ ├── caldav.py # CalDAV integration: full event lifecycle (create/list/search/update/delete), list_calendars, timezone (ZoneInfo), reminders (VALARM), attendees, multi-calendar search. Todo functions exist in code but are NOT exposed as LLM tools.
│ │ ├── settings.py # Settings CRUD with user_id isolation: get_setting, set_setting, set_settings_batch, get_all_settings
│ │ ├── logging.py # App logging: log_audit, log_usage, log_error, get_logs, get_log_stats, delete_old_logs, start_log_retention_loop
│ │ ├── email.py # SMTP email service: get_smtp_config, is_smtp_configured, send_email, send_test_email