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:
@@ -11,17 +11,16 @@ from quart import Blueprint, jsonify, request
|
|||||||
|
|
||||||
from fabledassistant.auth import get_current_user_id, login_required
|
from fabledassistant.auth import get_current_user_id, login_required
|
||||||
from fabledassistant.config import Config
|
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
|
from fabledassistant.services.tools import execute_tool, get_tools_for_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
quick_capture_bp = Blueprint("quick_capture", __name__, url_prefix="/api/quick-capture")
|
quick_capture_bp = Blueprint("quick_capture", __name__, url_prefix="/api/quick-capture")
|
||||||
|
|
||||||
# Only creation tools are offered to the intent classifier here — no delete,
|
# Tools offered to the quick-capture classifier. Excludes destructive ops
|
||||||
# update, search, or research. This keeps the fallback safe (worst case we
|
# (delete_*) and read-only queries — worst-case fallback is a plain note.
|
||||||
# create a plain note) and avoids accidental destructive actions.
|
_CAPTURE_TOOL_NAMES = {"create_note", "create_task", "create_event", "update_note", "research_topic"}
|
||||||
_CAPTURE_TOOL_NAMES = {"create_note", "create_task", "create_event", "create_todo"}
|
|
||||||
|
|
||||||
|
|
||||||
@quick_capture_bp.route("", methods=["POST"])
|
@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
|
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:
|
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)
|
result = await execute_tool(uid, intent.tool_name, intent.arguments)
|
||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
item_type = result.get("type", "note")
|
item_type = result.get("type", "note")
|
||||||
title = (result.get("data") or {}).get("title", "")
|
title = (result.get("data") or {}).get("title", "")
|
||||||
logger.info(
|
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,
|
uid, item_type, title, intent.tool_name,
|
||||||
)
|
)
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"success": True,
|
"success": True,
|
||||||
"type": item_type,
|
"type": item_type,
|
||||||
"message": f"{item_type.capitalize()} created: {title}",
|
"message": f"{item_type.capitalize()}: {title}",
|
||||||
"data": result.get("data"),
|
"data": result.get("data"),
|
||||||
})
|
})
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -66,13 +87,9 @@ async def quick_capture_route():
|
|||||||
# Fall through to plain-note fallback
|
# Fall through to plain-note fallback
|
||||||
|
|
||||||
# Fallback: create a plain note with the raw text as the body.
|
# 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.
|
# Always preserve the full text as the body; derive a short title.
|
||||||
if len(text) <= 80:
|
fallback_title = text if len(text) <= 80 else text[:77] + "..."
|
||||||
fallback_title = text
|
fallback_body = text
|
||||||
fallback_body = ""
|
|
||||||
else:
|
|
||||||
fallback_title = text[:77] + "..."
|
|
||||||
fallback_body = text
|
|
||||||
|
|
||||||
result = await execute_tool(
|
result = await execute_tool(
|
||||||
uid, "create_note", {"title": fallback_title, "body": fallback_body}
|
uid, "create_note", {"title": fallback_title, "body": fallback_body}
|
||||||
|
|||||||
@@ -54,12 +54,6 @@ _TOOL_LABELS: dict[str, str] = {
|
|||||||
"update_event": "Updating calendar event",
|
"update_event": "Updating calendar event",
|
||||||
"delete_event": "Removing calendar event",
|
"delete_event": "Removing calendar event",
|
||||||
"list_calendars": "Listing calendars",
|
"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",
|
"search_web": "Searching the web",
|
||||||
"research_topic": "Researching topic",
|
"research_topic": "Researching topic",
|
||||||
}
|
}
|
||||||
@@ -68,7 +62,6 @@ _TOOL_LABELS: dict[str, str] = {
|
|||||||
_WRITE_TOOLS: frozenset[str] = frozenset({
|
_WRITE_TOOLS: frozenset[str] = frozenset({
|
||||||
"create_task", "create_note", "update_note", "delete_note", "delete_task",
|
"create_task", "create_note", "update_note", "delete_note", "delete_task",
|
||||||
"create_event", "update_event", "delete_event",
|
"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}"
|
# 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_notes": "list notes",
|
||||||
"list_tasks": "look up tasks",
|
"list_tasks": "look up tasks",
|
||||||
"search_notes": "search through notes",
|
"search_notes": "search through notes",
|
||||||
"search_todos": "search calendar todos",
|
|
||||||
"create_event": "schedule a calendar event",
|
"create_event": "schedule a calendar event",
|
||||||
"list_events": "check the calendar",
|
"list_events": "check the calendar",
|
||||||
"search_events": "search calendar events",
|
"search_events": "search calendar events",
|
||||||
"update_event": "update a calendar event",
|
"update_event": "update a calendar event",
|
||||||
"delete_event": "remove a calendar event",
|
"delete_event": "remove a calendar event",
|
||||||
"list_calendars": "list available calendars",
|
"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",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- "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).
|
- 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.
|
- 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.
|
- "update", "change", "move", "reschedule" an event → use update_event.
|
||||||
- "delete", "cancel", "remove" an event → use delete_event.
|
- "delete", "cancel", "remove" an event → use delete_event.
|
||||||
- "which calendars", "list calendars", "my calendars" → use list_calendars.
|
- "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.
|
- "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.
|
- 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.
|
- "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)
|
return json.loads(text)
|
||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
return None
|
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)
|
||||||
|
|||||||
@@ -389,13 +389,10 @@ async def build_context(
|
|||||||
if has_caldav:
|
if has_caldav:
|
||||||
tool_lines[-1] = (
|
tool_lines[-1] = (
|
||||||
"Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes, "
|
"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, "
|
"create_event, list_events, search_events, update_event, delete_event, list_calendars."
|
||||||
"list_calendars, create_todo, list_todos, search_todos, update_todo, complete_todo, delete_todo."
|
|
||||||
)
|
)
|
||||||
tool_lines.append("For calendar events, use ISO 8601 datetime format (e.g. 2026-09-30T00:00:00).")
|
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("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("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("When creating notes, use the `tags` parameter — do not embed #tag text in the note body.")
|
||||||
tool_lines.append(
|
tool_lines.append(
|
||||||
|
|||||||
@@ -26,15 +26,16 @@ async def run_research_pipeline(
|
|||||||
user_id: int,
|
user_id: int,
|
||||||
model: str,
|
model: str,
|
||||||
intent_model: str,
|
intent_model: str,
|
||||||
buf,
|
buf=None,
|
||||||
) -> Note:
|
) -> Note:
|
||||||
"""Full research pipeline: search → fetch → synthesize → create 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.
|
Returns the created Note.
|
||||||
"""
|
"""
|
||||||
# Step 1: Generate sub-queries
|
# 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)
|
queries = await _generate_sub_queries(topic, intent_model)
|
||||||
logger.info("Research: generated %d sub-queries for topic '%s'", len(queries), topic)
|
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]]:
|
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
|
||||||
if i > 0:
|
if i > 0:
|
||||||
await asyncio.sleep(0.2 * i)
|
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)
|
results = await _search_searxng(query)
|
||||||
logger.info("Research: query '%s' → %d results", query, len(results))
|
logger.info("Research: query '%s' → %d results", query, len(results))
|
||||||
return query, results
|
return query, results
|
||||||
@@ -64,7 +66,8 @@ async def run_research_pipeline(
|
|||||||
# Fetch all unique URLs in parallel
|
# Fetch all unique URLs in parallel
|
||||||
async def _fetch_source(url: str, result: dict, query: str) -> dict:
|
async def _fetch_source(url: str, result: dict, query: str) -> dict:
|
||||||
title = result.get("title", url)
|
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)
|
content = await fetch_url_content(url)
|
||||||
return {
|
return {
|
||||||
"url": url,
|
"url": url,
|
||||||
@@ -98,11 +101,13 @@ async def run_research_pipeline(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Step 4: Synthesize (streams tokens into chat as the note is being written)
|
# 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)
|
title, body = await _synthesize_note(topic, synthesis_sources, model, buf)
|
||||||
|
|
||||||
# Step 5: Create note
|
# 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(
|
note = await create_note(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
title=title,
|
title=title,
|
||||||
|
|||||||
@@ -5,19 +5,13 @@ import logging
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from fabledassistant.services.caldav import (
|
from fabledassistant.services.caldav import (
|
||||||
complete_todo,
|
|
||||||
create_event,
|
create_event,
|
||||||
create_todo,
|
|
||||||
delete_event,
|
delete_event,
|
||||||
delete_todo,
|
|
||||||
is_caldav_configured,
|
is_caldav_configured,
|
||||||
list_calendars,
|
list_calendars,
|
||||||
list_events,
|
list_events,
|
||||||
list_todos,
|
|
||||||
search_events,
|
search_events,
|
||||||
search_todos,
|
|
||||||
update_event,
|
update_event,
|
||||||
update_todo,
|
|
||||||
)
|
)
|
||||||
from fabledassistant.config import Config
|
from fabledassistant.config import Config
|
||||||
from fabledassistant.services.notes import create_note, delete_note, get_note_by_title, list_notes, update_note
|
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":
|
elif tool_name == "get_note":
|
||||||
query = arguments.get("query", "")
|
query = arguments.get("query", "")
|
||||||
note = await get_note_by_title(user_id, query)
|
note = await get_note_by_title(user_id, query)
|
||||||
|
|||||||
+6
-6
@@ -12,7 +12,7 @@
|
|||||||
> Include file-level details in the commit body when the change is non-trivial.
|
> Include file-level details in the commit body when the change is non-trivial.
|
||||||
|
|
||||||
## Last Updated
|
## 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
|
## Project Overview
|
||||||
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
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)
|
│ │ ├── 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)
|
│ │ ├── 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)
|
│ │ ├── 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)
|
│ │ ├── 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)
|
│ │ └── settings.py # /api/settings GET/PUT, GET /models — per-user settings + installed model list (@login_required)
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
@@ -290,12 +290,12 @@ fabledassistant/
|
|||||||
│ │ ├── chat.py # Conversation CRUD with user_id isolation, add_message, save/summarize as note (LLM-titled, chat-tagged)
|
│ │ ├── 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_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
|
│ │ ├── 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
|
│ │ ├── 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
|
│ │ ├── 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() → Note; _search_searxng_images() for image category search; constants SEARXNG_QUERIES=5, PAGES_PER_QUERY=3, MAX_SYNTHESIS_SOURCES=12, CHARS_PER_SOURCE=2000
|
│ │ ├── 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
|
│ │ ├── 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
|
│ │ ├── 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
|
│ │ ├── 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
|
│ │ ├── email.py # SMTP email service: get_smtp_config, is_smtp_configured, send_email, send_test_email
|
||||||
|
|||||||
Reference in New Issue
Block a user