a2ba90160c
Project view: - Add inline status advance buttons on kanban task cards (todo→in_progress, in_progress→done); buttons reveal on hover, stop link navigation Task viewer: - Back button navigates to task's project instead of /tasks when project_id set - Esc key navigates to project (or /tasks); blurs focused element first Quick capture: - Use user's configured model instead of hardcoded Config.OLLAMA_MODEL - Remove create_project from classifier prompt (tool not offered, caused task-shaped inputs to silently fall through to note fallback) Briefing scheduler: - Fix get_event_loop() → get_running_loop() so background thread uses the correct hypercorn event loop (jobs were scheduling but never executing) - Suppress bare greeting when both LLM synthesis lanes return empty RSS feed UI (SettingsView): - Show last-fetched age, category badge, and feed URL per row - Category input field when adding a feed - Refresh all button: fetches latest items, reloads list, toasts with count - Enter key submits add-feed form; better empty-state hint with example feeds Weather tool: - Accept any city/region name in addition to 'home'/'work'/'all' - Geocodes via Nominatim + fetches live from Open-Meteo for arbitrary queries Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1691 lines
74 KiB
Python
1691 lines
74 KiB
Python
"""Tool definitions and executor for LLM tool calling."""
|
||
|
||
import asyncio
|
||
import logging
|
||
import re
|
||
from datetime import date, datetime
|
||
from difflib import SequenceMatcher
|
||
|
||
from fabledassistant.services.caldav import (
|
||
create_event,
|
||
delete_event,
|
||
is_caldav_configured,
|
||
list_calendars,
|
||
list_events,
|
||
search_events,
|
||
update_event,
|
||
)
|
||
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.tag_suggestions import suggest_tags
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _schedule_embedding(note_id: int, user_id: int, title: str, body: str) -> None:
|
||
"""Fire-and-forget: update the embedding for a note after it's created/modified."""
|
||
from fabledassistant.services.embeddings import upsert_note_embedding
|
||
text = f"{title}\n{body}".strip() if body else (title or "")
|
||
if text:
|
||
asyncio.create_task(upsert_note_embedding(note_id, user_id, text))
|
||
|
||
|
||
async def _resolve_project(user_id: int, project_name: str):
|
||
"""Exact-then-fuzzy project lookup. Returns the Project or None.
|
||
|
||
Resolution order:
|
||
1. Exact title match (case-insensitive via DB)
|
||
2. project_name is a substring of an existing title ("Famous Supply" in "Famous Supply Co.")
|
||
3. Existing title is a substring of project_name ("Famous Supply Co." in "Famous Supply Co. UK")
|
||
4. SequenceMatcher ratio >= 0.55 (catches abbreviations / partial names)
|
||
"""
|
||
from fabledassistant.services.projects import get_project_by_title, list_projects
|
||
proj = await get_project_by_title(user_id, project_name)
|
||
if proj is not None:
|
||
return proj
|
||
needle = project_name.lower().strip()
|
||
all_p = await list_projects(user_id)
|
||
# Substring checks first (deterministic)
|
||
for p in all_p:
|
||
haystack = p.title.lower().strip()
|
||
if needle in haystack or haystack in needle:
|
||
return p
|
||
# SequenceMatcher fallback for abbreviations / partial names
|
||
best, best_r = None, 0.0
|
||
for p in all_p:
|
||
r = SequenceMatcher(None, needle, p.title.lower().strip()).ratio()
|
||
if r >= 0.55 and r > best_r:
|
||
best, best_r = p, r
|
||
return best
|
||
|
||
|
||
# Core tools — always available
|
||
_CORE_TOOLS = [
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "create_task",
|
||
"description": "Create a new task for the user. Use this when the user asks you to add a task, todo, or reminder.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"title": {
|
||
"type": "string",
|
||
"description": "The task title",
|
||
},
|
||
"body": {
|
||
"type": "string",
|
||
"description": "Optional task description or details",
|
||
},
|
||
"due_date": {
|
||
"type": "string",
|
||
"description": "Optional due date in YYYY-MM-DD format",
|
||
},
|
||
"priority": {
|
||
"type": "string",
|
||
"enum": ["none", "low", "medium", "high"],
|
||
"description": "Task priority level",
|
||
},
|
||
"project": {
|
||
"type": "string",
|
||
"description": "Optional project name to assign this task to",
|
||
},
|
||
"parent_task": {
|
||
"type": "string",
|
||
"description": "Optional title of a parent task to make this a sub-task of",
|
||
},
|
||
"milestone": {
|
||
"type": "string",
|
||
"description": "Optional milestone title within the project to assign this task to",
|
||
},
|
||
"confirmed": {
|
||
"type": "boolean",
|
||
"description": "Set to true only when the user has explicitly confirmed creation after being warned about a similar existing task.",
|
||
},
|
||
},
|
||
"required": ["title"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "create_note",
|
||
"description": "Create a new note for the user. Use this when the user asks you to write down, save, or record something as a note.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"title": {
|
||
"type": "string",
|
||
"description": "The note title",
|
||
},
|
||
"body": {
|
||
"type": "string",
|
||
"description": "The note content in markdown",
|
||
},
|
||
"tags": {
|
||
"type": "array",
|
||
"items": {"type": "string"},
|
||
"description": "Tags for the note (without # prefix, hyphens for multi-word: [\"science-fiction\", \"story/idea\"]). Do NOT embed #tags in the note body.",
|
||
},
|
||
"project": {
|
||
"type": "string",
|
||
"description": "Optional project name to assign this note to",
|
||
},
|
||
"confirmed": {
|
||
"type": "boolean",
|
||
"description": "Set to true only when the user has explicitly confirmed creation after being warned about a similar existing note.",
|
||
},
|
||
},
|
||
"required": ["title"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "update_note",
|
||
"description": (
|
||
"Update an existing note or task — content, title, status, priority, or due date. "
|
||
"Use this when the user asks to add to, edit, expand, or modify a note, "
|
||
"OR to mark a task done/in-progress, change its priority, or set a due date. "
|
||
"NEVER use create_note when updating an existing note — always use update_note."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {
|
||
"type": "string",
|
||
"description": "Title or keyword to find the note or task to update",
|
||
},
|
||
"body": {
|
||
"type": "string",
|
||
"description": "New note content in markdown (omit if only updating task fields)",
|
||
},
|
||
"title": {
|
||
"type": "string",
|
||
"description": "Optional new title",
|
||
},
|
||
"mode": {
|
||
"type": "string",
|
||
"enum": ["replace", "append"],
|
||
"description": (
|
||
"How to apply the new body: 'replace' overwrites existing content (default), "
|
||
"'append' adds after existing content"
|
||
),
|
||
},
|
||
"status": {
|
||
"type": "string",
|
||
"enum": ["todo", "in_progress", "done"],
|
||
"description": "New task status. Use to mark a task done, start it, etc.",
|
||
},
|
||
"priority": {
|
||
"type": "string",
|
||
"enum": ["none", "low", "medium", "high"],
|
||
"description": "New task priority",
|
||
},
|
||
"due_date": {
|
||
"type": "string",
|
||
"description": "New due date in YYYY-MM-DD format",
|
||
},
|
||
"tags": {
|
||
"type": "array",
|
||
"items": {"type": "string"},
|
||
"description": "Tags to apply (see tag_mode for how they're applied)",
|
||
},
|
||
"tag_mode": {
|
||
"type": "string",
|
||
"enum": ["add", "remove", "replace"],
|
||
"description": "'add' appends tags without duplicates (default), 'remove' removes listed tags, 'replace' sets tags to exactly this list (destructive — avoid unless explicitly requested)",
|
||
},
|
||
"project": {
|
||
"type": "string",
|
||
"description": "Optional project name to assign this note/task to (set to empty string to remove project)",
|
||
},
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "search_notes",
|
||
"description": (
|
||
"Search notes and tasks using semantic understanding. Finds content related to a concept, "
|
||
"theme, or topic — even when exact keywords don't appear in the title or body. "
|
||
"Use this when the user asks what notes they have about a topic, wants to explore related "
|
||
"content, or is looking for something they've written. Falls back to keyword search "
|
||
"automatically if semantic search is unavailable."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {
|
||
"type": "string",
|
||
"description": "A natural-language description of what you're looking for — concepts, themes, topics, or keywords",
|
||
},
|
||
"type": {
|
||
"type": "string",
|
||
"enum": ["note", "task"],
|
||
"description": "Restrict results to only notes or only tasks. Omit to search both.",
|
||
},
|
||
"project": {
|
||
"type": "string",
|
||
"description": "Optional project name to restrict search to notes in that project",
|
||
},
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "get_note",
|
||
"description": (
|
||
"Retrieve the full content of a specific note. Use this when the user asks to read, "
|
||
"view, or check what a particular note says. Returns the complete note body."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {
|
||
"type": "string",
|
||
"description": "Title or keyword to identify the note",
|
||
},
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "list_notes",
|
||
"description": (
|
||
"Browse the user's notes (not tasks). Use this when the user asks to see, list, or browse "
|
||
"their notes — by recency, keyword, tag, or project. For tasks, use list_tasks instead."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"q": {
|
||
"type": "string",
|
||
"description": "Optional keyword filter",
|
||
},
|
||
"tags": {
|
||
"type": "array",
|
||
"items": {"type": "string"},
|
||
"description": "Filter notes that have ALL of these tags",
|
||
},
|
||
"project": {
|
||
"type": "string",
|
||
"description": "Filter notes by project name",
|
||
},
|
||
"sort": {
|
||
"type": "string",
|
||
"enum": ["updated_at", "created_at", "title"],
|
||
"description": "Sort field (default: updated_at)",
|
||
},
|
||
"limit": {
|
||
"type": "integer",
|
||
"description": "Maximum number of notes to return (default 10)",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "delete_note",
|
||
"description": (
|
||
"Delete a note permanently. Use ONLY when the user explicitly asks to delete or remove a note. "
|
||
"This action requires user confirmation and cannot be undone."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {
|
||
"type": "string",
|
||
"description": "Title or keyword to find the note to delete",
|
||
},
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "delete_task",
|
||
"description": (
|
||
"Delete a task permanently. Use ONLY when the user explicitly asks to delete or remove a task. "
|
||
"This action requires user confirmation and cannot be undone."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {
|
||
"type": "string",
|
||
"description": "Title or keyword to find the task to delete",
|
||
},
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "list_tasks",
|
||
"description": (
|
||
"List the user's tasks with optional filters. Use this when the user asks about their tasks "
|
||
"by status (e.g. 'what's in progress'), priority (e.g. 'high priority tasks'), "
|
||
"or due date (e.g. 'overdue tasks', 'due this week'). "
|
||
"For 'overdue', set due_before to today's date. For 'due today', set due_after to today and due_before to tomorrow."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"status": {
|
||
"type": "string",
|
||
"enum": ["todo", "in_progress", "done"],
|
||
"description": "Filter by task status",
|
||
},
|
||
"priority": {
|
||
"type": "string",
|
||
"enum": ["none", "low", "medium", "high"],
|
||
"description": "Filter by priority level",
|
||
},
|
||
"due_before": {
|
||
"type": "string",
|
||
"description": "Return tasks due before this date (YYYY-MM-DD). Use today's date to find overdue tasks.",
|
||
},
|
||
"due_after": {
|
||
"type": "string",
|
||
"description": "Return tasks due on or after this date (YYYY-MM-DD)",
|
||
},
|
||
"project": {
|
||
"type": "string",
|
||
"description": "Filter tasks by project name",
|
||
},
|
||
"milestone": {
|
||
"type": "string",
|
||
"description": "Filter tasks by milestone name within a project",
|
||
},
|
||
"limit": {
|
||
"type": "integer",
|
||
"description": "Maximum number of tasks to return (default 10)",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "create_project",
|
||
"description": "Create a brand-new project. IMPORTANT: NEVER call this because a project name was not found — always use list_projects first to confirm no similar project exists. Only call this when the user has explicitly asked to create a new project AND confirmed it. Call with confirmed=true only after the user has explicitly approved. The system will block creation if any project with a similar name already exists.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"title": {"type": "string", "description": "Project title"},
|
||
"description": {"type": "string", "description": "Brief description"},
|
||
"goal": {"type": "string", "description": "The goal or desired outcome"},
|
||
"confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this project created."},
|
||
},
|
||
"required": ["title", "confirmed"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "list_projects",
|
||
"description": "List the user's projects. Use when asked about projects, initiatives, or campaigns.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"status": {
|
||
"type": "string",
|
||
"enum": ["active", "completed", "archived"],
|
||
"description": "Filter by status (omit for all)",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "get_project",
|
||
"description": "Get details and summary of a specific project.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {"type": "string", "description": "Project name or keyword to find it"},
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "update_project",
|
||
"description": "Update a project's status, description, or goal.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {"type": "string", "description": "Project name to find"},
|
||
"status": {"type": "string", "enum": ["active", "completed", "archived"]},
|
||
"description": {"type": "string"},
|
||
"goal": {"type": "string"},
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "create_milestone",
|
||
"description": "Create a new milestone inside a project. IMPORTANT: You MUST ask the user to confirm the milestone name and which project it belongs to before calling this tool. Only call with confirmed=true after the user has explicitly approved creation.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"project": {"type": "string", "description": "Project title"},
|
||
"title": {"type": "string", "description": "Milestone title"},
|
||
"description": {"type": "string", "description": "Optional description"},
|
||
"confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this milestone created."},
|
||
},
|
||
"required": ["project", "title", "confirmed"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "update_milestone",
|
||
"description": "Update the title, description, or status of an existing milestone.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"project": {"type": "string", "description": "Project title the milestone belongs to"},
|
||
"milestone": {"type": "string", "description": "Current milestone title to look up"},
|
||
"title": {"type": "string", "description": "New title (omit to keep current)"},
|
||
"description": {"type": "string", "description": "New description (omit to keep current)"},
|
||
"status": {"type": "string", "enum": ["active", "completed", "cancelled"], "description": "New status (omit to keep current)"},
|
||
},
|
||
"required": ["project", "milestone"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "list_milestones",
|
||
"description": "List milestones for a project with their progress percentages.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"project": {"type": "string", "description": "Project name to look up"},
|
||
},
|
||
"required": ["project"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "log_work",
|
||
"description": (
|
||
"Add a work log entry to a task to record progress, work done, or time spent. "
|
||
"Use this when the user says they worked on, completed, or spent time on a task."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"task": {
|
||
"type": "string",
|
||
"description": "Title or keyword identifying the task (required)",
|
||
},
|
||
"content": {
|
||
"type": "string",
|
||
"description": "Description of the work done (required)",
|
||
},
|
||
"duration_minutes": {
|
||
"type": "integer",
|
||
"description": "Optional time spent in minutes",
|
||
},
|
||
},
|
||
"required": ["task", "content"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "get_weather",
|
||
"description": (
|
||
"Get the 7-day weather forecast. Pass a city/region name to look up any location, "
|
||
"or omit to get the user's configured home/work locations. "
|
||
"Returns temperature, precipitation, and conditions for each day."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"location": {
|
||
"type": "string",
|
||
"description": (
|
||
"City, region, or country to look up — e.g. 'Portland, Oregon', 'Tokyo', 'home', 'work'. "
|
||
"Use 'home' or 'work' to get the user's saved locations. Omit for all saved locations."
|
||
),
|
||
}
|
||
},
|
||
"required": [],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "get_rss_items",
|
||
"description": (
|
||
"Get recent items from the user's RSS feeds (news, blogs, Reddit, podcasts). "
|
||
"Returns titles, URLs, and summaries of recent posts."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"limit": {
|
||
"type": "integer",
|
||
"description": "Number of items to return (default 15, max 50)",
|
||
},
|
||
"category": {
|
||
"type": "string",
|
||
"description": "Filter by feed category (e.g. 'news', 'tech'). Omit for all.",
|
||
},
|
||
},
|
||
"required": [],
|
||
},
|
||
},
|
||
},
|
||
]
|
||
|
||
# CalDAV tools — only included when user has CalDAV configured
|
||
_CALDAV_TOOLS = [
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "create_event",
|
||
"description": "Create a calendar event for the user. Use this when the user asks to schedule, add, or create a meeting, appointment, or event.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"title": {
|
||
"type": "string",
|
||
"description": "A descriptive event title (e.g. 'John's Birthday' not just 'Birthday')",
|
||
},
|
||
"start": {
|
||
"type": "string",
|
||
"description": "Start date (YYYY-MM-DD for all-day) or datetime (ISO 8601, e.g. 2025-01-15T14:00:00)",
|
||
},
|
||
"end": {
|
||
"type": "string",
|
||
"description": "Optional end date or datetime in same format as start",
|
||
},
|
||
"duration": {
|
||
"type": "integer",
|
||
"description": "Optional duration in minutes (default 60, ignored if end is set or all_day is true)",
|
||
},
|
||
"description": {
|
||
"type": "string",
|
||
"description": "Optional event description",
|
||
},
|
||
"location": {
|
||
"type": "string",
|
||
"description": "Optional event location",
|
||
},
|
||
"all_day": {
|
||
"type": "boolean",
|
||
"description": "Set to true for all-day events like birthdays, holidays, deadlines (default false)",
|
||
},
|
||
"recurrence": {
|
||
"type": "string",
|
||
"description": "Optional iCalendar RRULE (e.g. 'FREQ=YEARLY' for annual, 'FREQ=WEEKLY' for weekly, 'FREQ=MONTHLY' for monthly)",
|
||
},
|
||
"reminder_minutes": {
|
||
"type": "integer",
|
||
"description": "Optional reminder N minutes before the event (e.g. 30 for 30 minutes before)",
|
||
},
|
||
"attendees": {
|
||
"type": "array",
|
||
"items": {"type": "string"},
|
||
"description": "Optional list of attendee email addresses",
|
||
},
|
||
"timezone": {
|
||
"type": "string",
|
||
"description": "Optional IANA timezone (e.g. 'America/New_York'). Falls back to user's caldav_timezone setting.",
|
||
},
|
||
"calendar_name": {
|
||
"type": "string",
|
||
"description": "Optional calendar name to create the event in. Falls back to default calendar.",
|
||
},
|
||
},
|
||
"required": ["title", "start"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "list_events",
|
||
"description": "List calendar events in a date range. Use this when the user asks what events or meetings they have.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"date_from": {
|
||
"type": "string",
|
||
"description": "Start of range in ISO 8601 format (e.g. 2025-01-15T00:00:00)",
|
||
},
|
||
"date_to": {
|
||
"type": "string",
|
||
"description": "End of range in ISO 8601 format (e.g. 2025-01-22T23:59:59)",
|
||
},
|
||
},
|
||
"required": ["date_from", "date_to"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "search_events",
|
||
"description": "Search calendar events by keyword. Use this when the user asks to find a specific event or meeting.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {
|
||
"type": "string",
|
||
"description": "Search keyword to match against event titles, locations, and descriptions",
|
||
},
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "update_event",
|
||
"description": "Update an existing calendar event. Use this when the user asks to change, move, reschedule, or modify an event.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {
|
||
"type": "string",
|
||
"description": "Search term to find the event to update (matches against title)",
|
||
},
|
||
"title": {
|
||
"type": "string",
|
||
"description": "New title for the event",
|
||
},
|
||
"start": {
|
||
"type": "string",
|
||
"description": "New start datetime in ISO 8601 format",
|
||
},
|
||
"end": {
|
||
"type": "string",
|
||
"description": "New end datetime in ISO 8601 format",
|
||
},
|
||
"description": {
|
||
"type": "string",
|
||
"description": "New event description",
|
||
},
|
||
"location": {
|
||
"type": "string",
|
||
"description": "New event location",
|
||
},
|
||
"timezone": {
|
||
"type": "string",
|
||
"description": "Optional IANA timezone (e.g. 'America/New_York')",
|
||
},
|
||
"calendar_name": {
|
||
"type": "string",
|
||
"description": "Optional calendar name to search in",
|
||
},
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "delete_event",
|
||
"description": "Delete a calendar event. Use this when the user asks to cancel, remove, or delete an event.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {
|
||
"type": "string",
|
||
"description": "Search term to find the event to delete (matches against title)",
|
||
},
|
||
"calendar_name": {
|
||
"type": "string",
|
||
"description": "Optional calendar name to search in",
|
||
},
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
},
|
||
},
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "list_calendars",
|
||
"description": "List all available calendars. Use this when the user asks which calendars they have.",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {},
|
||
},
|
||
},
|
||
},
|
||
]
|
||
|
||
|
||
_SEARCH_TOOLS = [
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "search_web",
|
||
"description": (
|
||
"Search the web for quick information and answer the user's question from results. "
|
||
"Use for factual lookups, current events, version numbers, quick definitions, or any question "
|
||
"where a fast web answer is needed without creating a note. "
|
||
"Use research_topic instead when the user explicitly wants a comprehensive note or deep research."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {"type": "string", "description": "The search query"}
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
},
|
||
}
|
||
]
|
||
|
||
_RESEARCH_TOOLS = [
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "research_topic",
|
||
"description": (
|
||
"Research a topic by searching the web and compiling a note with cited sources. "
|
||
"Use for 'research X', 'look up X', 'find information about X', "
|
||
"'compile notes on X'. Takes 30–120 seconds."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"topic": {
|
||
"type": "string",
|
||
"description": "The topic or question to research",
|
||
}
|
||
},
|
||
"required": ["topic"],
|
||
},
|
||
},
|
||
}
|
||
]
|
||
|
||
_IMAGE_TOOLS = [
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "search_images",
|
||
"description": (
|
||
"Search the web for images and display them inline in the response. "
|
||
"ONLY use this when the user explicitly asks to SEE, SHOW, VIEW, or DISPLAY "
|
||
"an image or photo — e.g. 'show me a picture of X', 'what does X look like', "
|
||
"'find a photo of X', 'display an image of X'. "
|
||
"Do NOT use for general descriptions, factual questions, or when the user just "
|
||
"wants information without visual content. Returns at most 2 images."
|
||
),
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"query": {
|
||
"type": "string",
|
||
"description": "The image search query",
|
||
}
|
||
},
|
||
"required": ["query"],
|
||
},
|
||
},
|
||
}
|
||
]
|
||
|
||
|
||
async def get_tools_for_user(user_id: int) -> list[dict]:
|
||
"""Build the tool list for a user based on their configured integrations."""
|
||
tools = list(_CORE_TOOLS)
|
||
if await is_caldav_configured(user_id):
|
||
tools.extend(_CALDAV_TOOLS)
|
||
if Config.searxng_enabled():
|
||
tools.extend(_SEARCH_TOOLS)
|
||
tools.extend(_RESEARCH_TOOLS)
|
||
tools.extend(_IMAGE_TOOLS)
|
||
logger.debug("User %d: %d tools available", user_id, len(tools))
|
||
return tools
|
||
|
||
|
||
def _parse_due_date(value: str | None) -> date | None:
|
||
"""Parse a due date string, returning None on failure."""
|
||
if not value:
|
||
return None
|
||
try:
|
||
return datetime.strptime(value, "%Y-%m-%d").date()
|
||
except (ValueError, TypeError):
|
||
logger.warning("Invalid due_date format: %s", value)
|
||
return None
|
||
|
||
|
||
_PUNCT_RE = re.compile(r"[^\w\s]")
|
||
|
||
|
||
def _fuzzy_title_match(title: str, candidates, threshold: float = 0.82):
|
||
"""Return (best_match, ratio) if any candidate's title is similar enough to *title*.
|
||
|
||
Uses SequenceMatcher ratio, which handles insertions/deletions/substitutions.
|
||
A threshold of 0.82 catches near-duplicates like "Game Premise" / "Game Premise Notes"
|
||
while leaving clearly different titles alone.
|
||
Returns (None, 0.0) when no candidate meets the threshold.
|
||
"""
|
||
needle = title.lower().strip()
|
||
best, best_r = None, 0.0
|
||
for c in candidates:
|
||
r = SequenceMatcher(None, needle, c.title.lower().strip()).ratio()
|
||
if r >= threshold and r > best_r:
|
||
best, best_r = c, r
|
||
return best, best_r
|
||
|
||
|
||
|
||
async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||
"""Execute a tool call and return the result."""
|
||
try:
|
||
if tool_name == "create_task":
|
||
task_title = arguments.get("title", "Untitled Task")
|
||
task_body = arguments.get("body", "")
|
||
if not isinstance(task_title, str):
|
||
return {"success": False, "error": "title must be a string. Call create_task once per task."}
|
||
if not isinstance(task_body, str):
|
||
task_body = ""
|
||
# Resolve project and milestone BEFORE creating to fail fast on not-found
|
||
project_name = arguments.get("project")
|
||
milestone_name = arguments.get("milestone")
|
||
parent_task_name = arguments.get("parent_task")
|
||
pre_fields: dict = {}
|
||
if project_name:
|
||
proj = await _resolve_project(user_id, project_name)
|
||
if proj is None:
|
||
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."}
|
||
pre_fields["project_id"] = proj.id
|
||
if milestone_name:
|
||
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt
|
||
ms = await _gmbt(user_id, proj.id, milestone_name)
|
||
if ms is None:
|
||
return {"success": False, "error": f"Milestone '{milestone_name}' not found in project '{proj.title}'. Use list_milestones to see available milestones."}
|
||
pre_fields["milestone_id"] = ms.id
|
||
|
||
existing_tasks, _ = await list_notes(user_id=user_id, q=task_title, is_task=True, limit=1)
|
||
exact = next((t for t in existing_tasks if t.title.lower() == task_title.lower()), None)
|
||
if exact is not None:
|
||
return {"success": False, "error": f"A task titled '{task_title}' already exists (id: {exact.id}). Use update_note to modify it instead of creating a duplicate."}
|
||
|
||
clean_q = _PUNCT_RE.sub(" ", task_title).strip()
|
||
candidates, _ = await list_notes(user_id=user_id, q=clean_q, is_task=True, limit=20)
|
||
near, ratio = _fuzzy_title_match(task_title, candidates)
|
||
if near is not None:
|
||
return {"success": False, "requires_confirmation": True, "similar_note": {"id": near.id, "title": near.title}, "error": f"A task with a very similar title '{near.title}' already exists (similarity: {ratio:.0%}). Ask the user to confirm before creating a separate entry."}
|
||
|
||
if not arguments.get("confirmed") and len(task_body.strip()) >= 80:
|
||
from fabledassistant.services.embeddings import semantic_search_notes as _ssn
|
||
sem_query = f"{task_title}\n{task_body}".strip()
|
||
sem_hits = await _ssn(user_id, sem_query, limit=3, threshold=0.90, is_task=True)
|
||
if sem_hits:
|
||
best_score, best_note = sem_hits[0]
|
||
return {"success": False, "requires_confirmation": True, "similar_note": {"id": best_note.id, "title": best_note.title}, "error": f"A task with very similar content exists: '{best_note.title}' (semantic similarity: {best_score:.0%}). Ask the user to confirm before creating a separate entry."}
|
||
|
||
note = await create_note(
|
||
user_id=user_id,
|
||
title=task_title,
|
||
body=task_body,
|
||
status="todo",
|
||
priority=arguments.get("priority", "none"),
|
||
due_date=_parse_due_date(arguments.get("due_date")),
|
||
project_id=pre_fields.get("project_id"),
|
||
milestone_id=pre_fields.get("milestone_id"),
|
||
)
|
||
suggested = await suggest_tags(user_id, task_title, task_body)
|
||
_schedule_embedding(note.id, user_id, task_title, task_body)
|
||
update_fields: dict = {}
|
||
if parent_task_name:
|
||
parent_notes, _ = await list_notes(user_id=user_id, q=parent_task_name, is_task=True, limit=1)
|
||
if parent_notes:
|
||
update_fields["parent_id"] = parent_notes[0].id
|
||
if update_fields:
|
||
note = await update_note(user_id, note.id, **update_fields)
|
||
return {
|
||
"success": True,
|
||
"type": "task",
|
||
"data": {
|
||
"id": note.id,
|
||
"title": note.title,
|
||
"status": note.status,
|
||
"priority": note.priority,
|
||
"due_date": str(note.due_date) if note.due_date else None,
|
||
"project_id": note.project_id,
|
||
},
|
||
"suggested_tags": suggested,
|
||
}
|
||
|
||
elif tool_name == "create_note":
|
||
note_title = arguments.get("title", "Untitled Note")
|
||
note_body = arguments.get("body", "")
|
||
note_tags = arguments.get("tags", [])
|
||
if not isinstance(note_title, str):
|
||
return {"success": False, "error": "title must be a string. Call create_note once per note."}
|
||
if not isinstance(note_body, str):
|
||
note_body = ""
|
||
if not isinstance(note_tags, list):
|
||
note_tags = []
|
||
# Resolve project BEFORE creating to fail fast on not-found
|
||
project_name = arguments.get("project")
|
||
project_id = None
|
||
if project_name:
|
||
proj = await _resolve_project(user_id, project_name)
|
||
if proj is None:
|
||
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."}
|
||
project_id = proj.id
|
||
|
||
existing_notes, _ = await list_notes(user_id=user_id, q=note_title, is_task=False, limit=1)
|
||
exact = next((n for n in existing_notes if n.title.lower() == note_title.lower()), None)
|
||
if exact is not None:
|
||
return {"success": False, "error": f"A note titled '{note_title}' already exists (id: {exact.id}). Use update_note to modify it instead of creating a duplicate."}
|
||
|
||
clean_q = _PUNCT_RE.sub(" ", note_title).strip()
|
||
candidates, _ = await list_notes(user_id=user_id, q=clean_q, is_task=False, limit=20)
|
||
near, ratio = _fuzzy_title_match(note_title, candidates)
|
||
if near is not None:
|
||
return {"success": False, "requires_confirmation": True, "similar_note": {"id": near.id, "title": near.title}, "error": f"A note with a very similar title '{near.title}' already exists (similarity: {ratio:.0%}). Ask the user to confirm before creating a separate entry."}
|
||
|
||
if not arguments.get("confirmed") and len(note_body.strip()) >= 80:
|
||
from fabledassistant.services.embeddings import semantic_search_notes as _ssn
|
||
sem_query = f"{note_title}\n{note_body}".strip()
|
||
sem_hits = await _ssn(user_id, sem_query, limit=3, threshold=0.90, is_task=False)
|
||
if sem_hits:
|
||
best_score, best_note = sem_hits[0]
|
||
return {"success": False, "requires_confirmation": True, "similar_note": {"id": best_note.id, "title": best_note.title}, "error": f"A note with very similar content exists: '{best_note.title}' (semantic similarity: {best_score:.0%}). Ask the user to confirm before creating a separate entry."}
|
||
|
||
note = await create_note(
|
||
user_id=user_id,
|
||
title=note_title,
|
||
body=note_body,
|
||
tags=note_tags,
|
||
project_id=project_id,
|
||
)
|
||
suggested = await suggest_tags(user_id, note_title, note_body)
|
||
_schedule_embedding(note.id, user_id, note_title, note_body)
|
||
return {
|
||
"success": True,
|
||
"type": "note",
|
||
"data": {
|
||
"id": note.id,
|
||
"title": note.title,
|
||
"project_id": note.project_id,
|
||
},
|
||
"suggested_tags": suggested,
|
||
}
|
||
|
||
elif tool_name == "update_note":
|
||
query = arguments.get("query", "")
|
||
new_body = arguments.get("body", "")
|
||
new_title = arguments.get("title")
|
||
mode = arguments.get("mode", "replace")
|
||
|
||
# Find the note: exact title match first, then fuzzy search
|
||
note = await get_note_by_title(user_id, query)
|
||
if note is None:
|
||
notes, _ = await list_notes(user_id=user_id, q=query, limit=5)
|
||
# Prefer notes (not tasks) when there are mixed results
|
||
note_only = [n for n in notes if n.status is None]
|
||
candidates = note_only or notes
|
||
if not candidates:
|
||
return {"success": False, "error": f"No note found matching '{query}'."}
|
||
note = candidates[0]
|
||
|
||
# Build the update payload
|
||
update_fields: dict = {}
|
||
if new_title:
|
||
update_fields["title"] = new_title
|
||
if new_body:
|
||
if mode == "append" and note.body:
|
||
update_fields["body"] = note.body + "\n\n" + new_body
|
||
else:
|
||
update_fields["body"] = new_body
|
||
if "status" in arguments:
|
||
update_fields["status"] = arguments["status"]
|
||
if "priority" in arguments:
|
||
update_fields["priority"] = arguments["priority"]
|
||
if "due_date" in arguments:
|
||
update_fields["due_date"] = _parse_due_date(arguments["due_date"])
|
||
if "tags" in arguments:
|
||
new_tags = arguments["tags"]
|
||
if isinstance(new_tags, list):
|
||
tag_mode = arguments.get("tag_mode", "add")
|
||
if tag_mode == "add":
|
||
existing = list(note.tags or [])
|
||
for t in new_tags:
|
||
if t not in existing:
|
||
existing.append(t)
|
||
update_fields["tags"] = existing
|
||
elif tag_mode == "remove":
|
||
existing = list(note.tags or [])
|
||
update_fields["tags"] = [t for t in existing if t not in new_tags]
|
||
else:
|
||
update_fields["tags"] = new_tags
|
||
|
||
if "project" in arguments:
|
||
project_name = arguments["project"]
|
||
if project_name:
|
||
proj = await _resolve_project(user_id, project_name)
|
||
if proj is None:
|
||
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects."}
|
||
update_fields["project_id"] = proj.id
|
||
else:
|
||
update_fields["project_id"] = None
|
||
|
||
updated = await update_note(user_id, note.id, **update_fields)
|
||
if updated is None:
|
||
return {"success": False, "error": "Failed to update note."}
|
||
|
||
suggested = await suggest_tags(user_id, updated.title, updated.body or "")
|
||
_schedule_embedding(updated.id, user_id, updated.title, updated.body or "")
|
||
item_type = "task" if updated.status is not None else "note"
|
||
return {
|
||
"success": True,
|
||
"type": "note_updated",
|
||
"updated": f"{item_type} '{updated.title}' (id: {updated.id})",
|
||
"data": {
|
||
"id": updated.id,
|
||
"title": updated.title,
|
||
"item_type": item_type,
|
||
"status": updated.status,
|
||
"tags": updated.tags or [],
|
||
"project_id": updated.project_id,
|
||
},
|
||
"suggested_tags": suggested,
|
||
}
|
||
|
||
elif tool_name == "list_tasks":
|
||
project_id = None
|
||
milestone_id = None
|
||
project_name = arguments.get("project")
|
||
milestone_name = arguments.get("milestone")
|
||
if project_name:
|
||
proj = await _resolve_project(user_id, project_name)
|
||
if proj:
|
||
project_id = proj.id
|
||
if milestone_name:
|
||
from fabledassistant.services.milestones import get_milestone_by_title
|
||
ms = await get_milestone_by_title(user_id, proj.id, milestone_name)
|
||
if ms:
|
||
milestone_id = ms.id
|
||
elif milestone_name:
|
||
# No project specified — search milestone by name across all projects
|
||
from fabledassistant.services.milestones import find_milestone_by_title
|
||
ms = await find_milestone_by_title(user_id, milestone_name)
|
||
if ms:
|
||
milestone_id = ms.id
|
||
notes, total = await list_notes(
|
||
user_id=user_id,
|
||
is_task=True,
|
||
status=arguments.get("status"),
|
||
priority=arguments.get("priority"),
|
||
due_before=_parse_due_date(arguments.get("due_before")),
|
||
due_after=_parse_due_date(arguments.get("due_after")),
|
||
project_id=project_id,
|
||
milestone_id=milestone_id,
|
||
limit=int(arguments.get("limit", 10)),
|
||
sort="due_date",
|
||
order="asc",
|
||
)
|
||
results = []
|
||
for n in notes:
|
||
results.append({
|
||
"id": n.id,
|
||
"title": n.title,
|
||
"status": n.status,
|
||
"priority": n.priority,
|
||
"due_date": str(n.due_date) if n.due_date else None,
|
||
"project_id": n.project_id,
|
||
"preview": (n.body[:120] + "...") if n.body and len(n.body) > 120 else (n.body or ""),
|
||
})
|
||
return {
|
||
"success": True,
|
||
"type": "tasks",
|
||
"data": {
|
||
"total": total,
|
||
"count": len(results),
|
||
"results": results,
|
||
},
|
||
}
|
||
|
||
elif tool_name == "search_notes":
|
||
query = arguments.get("query", "")
|
||
type_filter = arguments.get("type")
|
||
project_name = arguments.get("project")
|
||
|
||
is_task: bool | None = None
|
||
if type_filter == "note":
|
||
is_task = False
|
||
elif type_filter == "task":
|
||
is_task = True
|
||
|
||
# Resolve project name → id
|
||
search_project_id: int | None = None
|
||
if project_name:
|
||
proj = await _resolve_project(user_id, project_name)
|
||
if proj:
|
||
search_project_id = proj.id
|
||
|
||
results_map: dict[int, dict] = {}
|
||
|
||
# 1) Semantic search — broad threshold (0.40) since this is an intentional query
|
||
try:
|
||
from fabledassistant.services.embeddings import semantic_search_notes as _ssn
|
||
sem_results = await _ssn(
|
||
user_id, query, limit=8, threshold=0.40,
|
||
project_id=search_project_id,
|
||
)
|
||
for score, n in sem_results:
|
||
n_is_task = n.status is not None
|
||
if is_task is True and not n_is_task:
|
||
continue
|
||
if is_task is False and n_is_task:
|
||
continue
|
||
results_map[n.id] = {
|
||
"id": n.id,
|
||
"title": n.title,
|
||
"type": "task" if n_is_task else "note",
|
||
"status": n.status,
|
||
"relevance": f"{round(score * 100)}%",
|
||
"preview": (n.body[:300] + "…") if n.body and len(n.body) > 300 else (n.body or ""),
|
||
}
|
||
logger.debug("search_notes semantic: %d results for %r", len(results_map), query)
|
||
except Exception:
|
||
logger.debug("Semantic search unavailable in search_notes, using keyword only", exc_info=True)
|
||
|
||
# 2) Keyword search — merge in anything not already found
|
||
try:
|
||
kw_notes, _ = await list_notes(
|
||
user_id=user_id, q=query, is_task=is_task,
|
||
project_id=search_project_id, limit=8,
|
||
)
|
||
for n in kw_notes:
|
||
if n.id not in results_map:
|
||
results_map[n.id] = {
|
||
"id": n.id,
|
||
"title": n.title,
|
||
"type": "task" if n.status is not None else "note",
|
||
"status": n.status,
|
||
"relevance": "keyword",
|
||
"preview": (n.body[:300] + "…") if n.body and len(n.body) > 300 else (n.body or ""),
|
||
}
|
||
except Exception:
|
||
logger.warning("Keyword fallback in search_notes failed", exc_info=True)
|
||
|
||
results = list(results_map.values())[:8]
|
||
return {
|
||
"success": True,
|
||
"type": "search",
|
||
"data": {
|
||
"query": query,
|
||
"count": len(results),
|
||
"results": results,
|
||
},
|
||
}
|
||
|
||
elif tool_name == "create_event":
|
||
result = await create_event(
|
||
user_id=user_id,
|
||
title=arguments.get("title", "Untitled Event"),
|
||
start=arguments["start"],
|
||
end=arguments.get("end"),
|
||
duration=arguments.get("duration"),
|
||
description=arguments.get("description"),
|
||
location=arguments.get("location"),
|
||
all_day=arguments.get("all_day", False),
|
||
recurrence=arguments.get("recurrence"),
|
||
timezone=arguments.get("timezone"),
|
||
reminder_minutes=arguments.get("reminder_minutes"),
|
||
attendees=arguments.get("attendees"),
|
||
calendar_name=arguments.get("calendar_name"),
|
||
)
|
||
return {
|
||
"success": True,
|
||
"type": "event",
|
||
"data": result,
|
||
}
|
||
|
||
elif tool_name == "list_events":
|
||
events = await list_events(
|
||
user_id=user_id,
|
||
date_from=arguments["date_from"],
|
||
date_to=arguments["date_to"],
|
||
)
|
||
return {
|
||
"success": True,
|
||
"type": "events",
|
||
"data": {
|
||
"count": len(events),
|
||
"events": events,
|
||
},
|
||
}
|
||
|
||
elif tool_name == "search_events":
|
||
events = await search_events(
|
||
user_id=user_id,
|
||
query=arguments.get("query", ""),
|
||
)
|
||
return {
|
||
"success": True,
|
||
"type": "events",
|
||
"data": {
|
||
"query": arguments.get("query", ""),
|
||
"count": len(events),
|
||
"events": events,
|
||
},
|
||
}
|
||
|
||
elif tool_name == "update_event":
|
||
result = await update_event(
|
||
user_id=user_id,
|
||
query=arguments["query"],
|
||
title=arguments.get("title"),
|
||
start=arguments.get("start"),
|
||
end=arguments.get("end"),
|
||
description=arguments.get("description"),
|
||
location=arguments.get("location"),
|
||
timezone=arguments.get("timezone"),
|
||
calendar_name=arguments.get("calendar_name"),
|
||
)
|
||
return {
|
||
"success": True,
|
||
"type": "event_updated",
|
||
"data": result,
|
||
}
|
||
|
||
elif tool_name == "delete_event":
|
||
result = await delete_event(
|
||
user_id=user_id,
|
||
query=arguments["query"],
|
||
calendar_name=arguments.get("calendar_name"),
|
||
)
|
||
return {
|
||
"success": True,
|
||
"type": "event_deleted",
|
||
"data": result,
|
||
}
|
||
|
||
elif tool_name == "list_calendars":
|
||
calendars = await list_calendars(user_id=user_id)
|
||
return {
|
||
"success": True,
|
||
"type": "calendars",
|
||
"data": {
|
||
"count": len(calendars),
|
||
"calendars": calendars,
|
||
},
|
||
}
|
||
|
||
elif tool_name == "get_note":
|
||
query = arguments.get("query", "")
|
||
note = await get_note_by_title(user_id, query)
|
||
if note is None:
|
||
notes, _ = await list_notes(user_id=user_id, q=query, limit=3)
|
||
if not notes:
|
||
return {"success": False, "error": f"No note found matching '{query}'."}
|
||
note = notes[0]
|
||
return {
|
||
"success": True,
|
||
"type": "note_content",
|
||
"data": {
|
||
"id": note.id,
|
||
"title": note.title,
|
||
"body": note.body or "",
|
||
"tags": note.tags or [],
|
||
},
|
||
}
|
||
|
||
elif tool_name == "list_notes":
|
||
tags_raw = arguments.get("tags")
|
||
tags = tags_raw if isinstance(tags_raw, list) else None
|
||
project_id = None
|
||
project_name = arguments.get("project")
|
||
if project_name:
|
||
proj = await _resolve_project(user_id, project_name)
|
||
if proj is None:
|
||
return {"success": False, "error": f"Project '{project_name}' not found."}
|
||
project_id = proj.id
|
||
notes, total = await list_notes(
|
||
user_id=user_id,
|
||
q=arguments.get("q") or arguments.get("query"),
|
||
tags=tags,
|
||
is_task=False,
|
||
project_id=project_id,
|
||
sort=arguments.get("sort", "updated_at"),
|
||
order="desc",
|
||
limit=int(arguments.get("limit", 10)),
|
||
)
|
||
results = [
|
||
{
|
||
"id": n.id,
|
||
"title": n.title,
|
||
"tags": n.tags or [],
|
||
"updated_at": n.updated_at.isoformat(),
|
||
"preview": (n.body[:200] + "...") if n.body and len(n.body) > 200 else (n.body or ""),
|
||
}
|
||
for n in notes
|
||
]
|
||
return {
|
||
"success": True,
|
||
"type": "notes_list",
|
||
"data": {
|
||
"total": total,
|
||
"count": len(results),
|
||
"results": results,
|
||
},
|
||
}
|
||
|
||
elif tool_name == "delete_note":
|
||
query = arguments.get("query", "")
|
||
note = await get_note_by_title(user_id, query)
|
||
if note is None:
|
||
notes, _ = await list_notes(user_id=user_id, q=query, is_task=False, limit=3)
|
||
if not notes:
|
||
return {"success": False, "error": f"No note found matching '{query}'."}
|
||
note = notes[0]
|
||
if note.status is not None:
|
||
return {"success": False, "error": f"'{note.title}' is a task. Use delete_task instead."}
|
||
deleted = await delete_note(user_id, note.id)
|
||
if not deleted:
|
||
return {"success": False, "error": "Failed to delete note."}
|
||
return {
|
||
"success": True,
|
||
"type": "note_deleted",
|
||
"data": {"id": note.id, "title": note.title},
|
||
}
|
||
|
||
elif tool_name == "delete_task":
|
||
query = arguments.get("query", "")
|
||
note = await get_note_by_title(user_id, query)
|
||
if note is None:
|
||
notes, _ = await list_notes(user_id=user_id, q=query, is_task=True, limit=3)
|
||
if not notes:
|
||
return {"success": False, "error": f"No task found matching '{query}'."}
|
||
note = notes[0]
|
||
if note.status is None:
|
||
return {"success": False, "error": f"'{note.title}' is a note. Use delete_note instead."}
|
||
deleted = await delete_note(user_id, note.id)
|
||
if not deleted:
|
||
return {"success": False, "error": "Failed to delete task."}
|
||
return {
|
||
"success": True,
|
||
"type": "task_deleted",
|
||
"data": {"id": note.id, "title": note.title},
|
||
}
|
||
|
||
elif tool_name == "create_milestone":
|
||
from fabledassistant.services.milestones import create_milestone as _cm, get_project_milestone_summary
|
||
project_name = arguments.get("project", "")
|
||
ms_title_pre = arguments.get("title", "Untitled Milestone")
|
||
if not project_name:
|
||
return {"success": False, "error": "project is required"}
|
||
if not arguments.get("confirmed"):
|
||
return {"success": False, "requires_confirmation": True, "error": f"Milestone creation requires explicit user confirmation. Ask the user: 'Shall I create a milestone titled \"{ms_title_pre}\" in project \"{project_name}\"?' Then retry with confirmed=true if they agree."}
|
||
proj = await _resolve_project(user_id, project_name)
|
||
if proj is None:
|
||
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."}
|
||
ms_title = ms_title_pre
|
||
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt2, list_milestones as _lms
|
||
existing_ms = await _gmbt2(user_id, proj.id, ms_title)
|
||
if existing_ms is not None:
|
||
return {"success": False, "error": f"A milestone titled '{existing_ms.title}' already exists in '{proj.title}' (id: {existing_ms.id}). Use update_milestone to modify it instead."}
|
||
all_ms = await _lms(user_id, proj.id)
|
||
near_ms, ratio = _fuzzy_title_match(ms_title, all_ms)
|
||
if near_ms is not None:
|
||
return {"success": False, "error": f"A milestone with a very similar title '{near_ms.title}' already exists in '{proj.title}' (id: {near_ms.id}, similarity: {ratio:.0%}). Use update_milestone to modify it instead."}
|
||
ms = await _cm(
|
||
user_id,
|
||
proj.id,
|
||
title=ms_title,
|
||
description=arguments.get("description"),
|
||
)
|
||
return {"success": True, "type": "milestone", "data": ms.to_dict()}
|
||
|
||
elif tool_name == "update_milestone":
|
||
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt, update_milestone as _um
|
||
project_name = arguments.get("project", "")
|
||
milestone_name = arguments.get("milestone", "")
|
||
if not project_name or not milestone_name:
|
||
return {"success": False, "error": "Both project and milestone are required"}
|
||
proj = await _resolve_project(user_id, project_name)
|
||
if proj is None:
|
||
return {"success": False, "error": f"Project '{project_name}' not found"}
|
||
ms = await _gmbt(user_id, proj.id, milestone_name)
|
||
if ms is None:
|
||
return {"success": False, "error": f"Milestone '{milestone_name}' not found in project '{proj.title}'. Use list_milestones to see available milestones."}
|
||
fields: dict[str, object] = {}
|
||
if "title" in arguments:
|
||
fields["title"] = arguments["title"]
|
||
if "description" in arguments:
|
||
fields["description"] = arguments["description"]
|
||
if "status" in arguments:
|
||
fields["status"] = arguments["status"]
|
||
if not fields:
|
||
return {"success": False, "error": "No fields to update — provide at least one of: title, description, status"}
|
||
updated = await _um(user_id, ms.id, **fields)
|
||
return {"success": True, "type": "milestone", "data": updated.to_dict()}
|
||
|
||
elif tool_name == "list_milestones":
|
||
from fabledassistant.services.milestones import get_project_milestone_summary
|
||
project_name = arguments.get("project", "")
|
||
proj = await _resolve_project(user_id, project_name)
|
||
if proj is None:
|
||
return {"success": False, "error": f"No project found matching '{project_name}'"}
|
||
summary = await get_project_milestone_summary(user_id, proj.id)
|
||
return {
|
||
"success": True,
|
||
"type": "milestones_list",
|
||
"data": {"project": proj.title, "count": len(summary), "milestones": summary},
|
||
}
|
||
|
||
elif tool_name == "search_web":
|
||
from fabledassistant.services.research import _search_searxng
|
||
query = arguments.get("query", "")
|
||
results = await _search_searxng(query)
|
||
if not results:
|
||
return {"success": False, "error": f"No results found for '{query}'"}
|
||
return {
|
||
"success": True,
|
||
"type": "web_search",
|
||
"data": {
|
||
"query": query,
|
||
"results": results,
|
||
"count": len(results),
|
||
},
|
||
}
|
||
|
||
elif tool_name == "research_topic":
|
||
# Research is always intercepted in generation_task.py (round 0) before execute_tool.
|
||
# Reaching here indicates a code path regression.
|
||
topic = arguments.get("topic", "")
|
||
logger.error("research_topic reached execute_tool — should have been intercepted upstream in generation_task.py. topic=%r", topic)
|
||
return {
|
||
"success": False,
|
||
"error": "Research could not be started. Please try again.",
|
||
}
|
||
|
||
elif tool_name == "create_project":
|
||
from fabledassistant.services.projects import create_project as _create_project, get_project_by_title as _gpbt, list_projects as _lp
|
||
proj_title = arguments.get("title", "New Project")
|
||
# Always check for existing/similar projects first — even when confirmed=true
|
||
existing_proj = await _gpbt(user_id, proj_title)
|
||
if existing_proj is not None:
|
||
return {"success": False, "error": f"A project titled '{existing_proj.title}' already exists (id: {existing_proj.id}). Use that project instead of creating a duplicate."}
|
||
all_projects = await _lp(user_id)
|
||
# Broad fuzzy check (0.55) catches partial names and abbreviations
|
||
near_proj, ratio = _fuzzy_title_match(proj_title, all_projects, threshold=0.55)
|
||
if near_proj is not None:
|
||
return {"success": False, "requires_confirmation": True, "error": f"A project with a similar name '{near_proj.title}' already exists (id: {near_proj.id}, similarity: {ratio:.0%}). This is likely the project you meant — use that project's name instead. Only call create_project again if you are certain this should be a completely new, separate project with a different confirmed=true."}
|
||
if not arguments.get("confirmed"):
|
||
return {"success": False, "requires_confirmation": True, "error": f"Project creation requires explicit user confirmation. Ask the user: 'Shall I create a new project titled \"{proj_title}\"?' Then retry with confirmed=true if they agree."}
|
||
project = await _create_project(
|
||
user_id,
|
||
title=proj_title,
|
||
description=arguments.get("description", ""),
|
||
goal=arguments.get("goal", ""),
|
||
)
|
||
return {"success": True, "type": "project", "data": project.to_dict()}
|
||
|
||
elif tool_name == "list_projects":
|
||
from fabledassistant.services.projects import list_projects as _list_projects
|
||
projects = await _list_projects(user_id, status=arguments.get("status"))
|
||
return {
|
||
"success": True,
|
||
"type": "projects_list",
|
||
"data": {"count": len(projects), "projects": [p.to_dict() for p in projects]},
|
||
}
|
||
|
||
elif tool_name == "get_project":
|
||
from fabledassistant.services.projects import (
|
||
get_project_by_title as _gpbt,
|
||
get_project_summary as _gps,
|
||
list_projects as _lp,
|
||
)
|
||
query = arguments.get("query", "")
|
||
project = await _gpbt(user_id, query)
|
||
if project is None:
|
||
all_projects = await _lp(user_id)
|
||
matches = [p for p in all_projects if query.lower() in p.title.lower()]
|
||
if matches:
|
||
project = matches[0]
|
||
if project is None:
|
||
return {"success": False, "error": f"No project found matching '{query}'"}
|
||
summary = await _gps(user_id, project.id)
|
||
data = project.to_dict()
|
||
data["summary"] = summary
|
||
return {"success": True, "type": "project_detail", "data": data}
|
||
|
||
elif tool_name == "update_project":
|
||
from fabledassistant.services.projects import (
|
||
get_project_by_title as _gpbt,
|
||
update_project as _up,
|
||
list_projects as _lp,
|
||
)
|
||
query = arguments.get("query", "")
|
||
project = await _gpbt(user_id, query)
|
||
if project is None:
|
||
all_projects = await _lp(user_id)
|
||
matches = [p for p in all_projects if query.lower() in p.title.lower()]
|
||
if matches:
|
||
project = matches[0]
|
||
if project is None:
|
||
return {"success": False, "error": f"No project found matching '{query}'"}
|
||
fields = {}
|
||
for k in ("status", "description", "goal"):
|
||
if k in arguments:
|
||
fields[k] = arguments[k]
|
||
updated = await _up(user_id, project.id, **fields)
|
||
return {"success": True, "type": "project_updated", "data": updated.to_dict()}
|
||
|
||
elif tool_name == "search_images":
|
||
from fabledassistant.services.images import fetch_and_store_image
|
||
from fabledassistant.services.research import _search_searxng_images
|
||
query = arguments.get("query", "")
|
||
if not query:
|
||
return {"success": False, "error": "query is required"}
|
||
raw_results = await _search_searxng_images(query)
|
||
if not raw_results:
|
||
return {"success": False, "error": f"No images found for '{query}'"}
|
||
# Fetch and cache up to 2 images
|
||
images = []
|
||
for r in raw_results[:2]:
|
||
img_url = r.get("img_src", "")
|
||
if not img_url:
|
||
continue
|
||
record = await fetch_and_store_image(
|
||
url=img_url,
|
||
title=r.get("title"),
|
||
source_domain=r.get("source_domain"),
|
||
)
|
||
if record:
|
||
title = record.title or query
|
||
page_url = r.get("page_url", "")
|
||
source_domain = r.get("source_domain", "")
|
||
images.append({
|
||
"embed": f"",
|
||
"citation": f"*Source: [{source_domain or 'image'}]({page_url})*",
|
||
"page_url": page_url,
|
||
"title": title,
|
||
})
|
||
if not images:
|
||
return {"success": False, "error": f"Could not retrieve images for '{query}'"}
|
||
return {
|
||
"success": True,
|
||
"type": "image_search",
|
||
"data": {
|
||
"query": query,
|
||
"images": images,
|
||
"instructions": (
|
||
"Embed each image in your response by writing the 'embed' field verbatim, "
|
||
"then the 'citation' field on the next line. Do not paraphrase or list URLs."
|
||
),
|
||
},
|
||
}
|
||
|
||
elif tool_name == "log_work":
|
||
from fabledassistant.services.task_logs import create_log as _create_log
|
||
task_query = arguments.get("task", "")
|
||
content = arguments.get("content", "").strip()
|
||
if not task_query:
|
||
return {"success": False, "error": "task is required"}
|
||
if not content:
|
||
return {"success": False, "error": "content is required"}
|
||
duration_minutes = arguments.get("duration_minutes")
|
||
if duration_minutes is not None:
|
||
try:
|
||
duration_minutes = int(duration_minutes)
|
||
except (TypeError, ValueError):
|
||
duration_minutes = None
|
||
# Resolve task by exact title then fuzzy search (tasks only)
|
||
note = await get_note_by_title(user_id, task_query)
|
||
if note is None or note.status is None:
|
||
notes, _ = await list_notes(user_id=user_id, q=task_query, is_task=True, limit=5)
|
||
note = notes[0] if notes else None
|
||
if note is None:
|
||
return {"success": False, "error": f"No task found matching '{task_query}'."}
|
||
try:
|
||
log = await _create_log(user_id, note.id, content, duration_minutes)
|
||
except ValueError as e:
|
||
return {"success": False, "error": str(e)}
|
||
return {"success": True, "log": log.to_dict(), "task": note.title}
|
||
|
||
elif tool_name == "get_weather":
|
||
from fabledassistant.services.weather import (
|
||
get_cached_weather, geocode, _fetch_open_meteo, parse_forecast
|
||
)
|
||
from datetime import datetime, timezone as _tz
|
||
location = (arguments.get("location") or "").strip()
|
||
_known = {"home", "work", "all", ""}
|
||
if location.lower() not in _known:
|
||
# Live geocode + fetch for arbitrary city
|
||
try:
|
||
lat, lon, label = await geocode(location)
|
||
raw = await _fetch_open_meteo(lat, lon)
|
||
days = parse_forecast(raw)
|
||
return {"data": {"locations": [{
|
||
"location_key": "query",
|
||
"location_label": label,
|
||
"fetched_at": datetime.now(_tz.utc).isoformat(),
|
||
"days": days,
|
||
"changes_since_last_fetch": [],
|
||
}]}}
|
||
except Exception as e:
|
||
return {"success": False, "error": f"Could not get weather for '{location}': {e}"}
|
||
# Fall back to cached configured locations
|
||
locations = await get_cached_weather(user_id)
|
||
if location.lower() not in ("all", ""):
|
||
locations = [loc for loc in locations if loc["location_key"] == location.lower()]
|
||
return {"data": {"locations": locations}}
|
||
|
||
elif tool_name == "get_rss_items":
|
||
from fabledassistant.services.rss import get_recent_items
|
||
limit = min(int(arguments.get("limit", 15)), 50)
|
||
items = await get_recent_items(user_id, limit=limit)
|
||
return {"data": {"items": items, "count": len(items)}}
|
||
|
||
else:
|
||
return {"success": False, "error": f"Unknown tool: {tool_name}"}
|
||
|
||
except Exception as e:
|
||
logger.exception("Tool execution failed: %s", tool_name)
|
||
return {"success": False, "error": str(e)}
|