diff --git a/src/fabledassistant/services/briefing_tools.py b/src/fabledassistant/services/briefing_tools.py index 295cad2..5925402 100644 --- a/src/fabledassistant/services/briefing_tools.py +++ b/src/fabledassistant/services/briefing_tools.py @@ -1,62 +1,9 @@ -""" -Curated read-only tool subset for agentic briefings. +"""Briefing tool subset — delegates to the registry's ``briefing=True`` filter. -The main chat pipeline exposes 40+ tools via ``tools.get_tools_for_user``, -including mutating tools (``create_task``, ``delete_note``) and -external-search tools (``search_images``, ``search_web``). Neither is -appropriate for a scheduled background job that generates briefings — -briefings are read-only and should not reach out to the internet on the -user's behalf, and leaving high-noise tools in the list increases the -chance of spurious calls (e.g. ``search_images`` firing on "what -meeting?"). - -This module maintains an explicit allowlist. New tools added to -``tools.py`` are not automatically exposed to briefings — they must be -opted in by name here. +Tools are opted-in to briefings via ``@tool(briefing=True)`` in their +respective module, so there is no separate allowlist to maintain here. """ -import logging +from fabledassistant.services.tools import get_briefing_tools -from fabledassistant.services.tools import get_tools_for_user - -logger = logging.getLogger(__name__) - -# Explicit allowlist — tools a briefing is permitted to call. Read-only only. -# Any tool not listed here is invisible to the briefing model. -BRIEFING_TOOL_NAMES: frozenset[str] = frozenset({ - # Tasks — what's actionable today, overdue, or high priority - "list_tasks", - # Calendar — internal event store and CalDAV (if configured) - "list_events", - "search_events", - # Weather — today's forecast for the user's configured locations - "get_weather", - # News — RSS items filtered by user preferences - "get_rss_items", - # Projects — context for prioritization and narrative continuity - "list_projects", - "search_projects", - "get_project", - # Notes — surface recent captures for "pick up where you left off" - "list_notes", - "get_note", -}) - - -async def get_briefing_tools(user_id: int) -> list[dict]: - """Return the tool schemas a briefing run is permitted to call. - - Builds the user's full tool list (so user-specific gating such as - CalDAV availability still applies) and filters it down to the - briefing allowlist. - """ - all_tools = await get_tools_for_user(user_id) - filtered = [ - t for t in all_tools - if t.get("function", {}).get("name") in BRIEFING_TOOL_NAMES - ] - logger.debug( - "Briefing tools for user %d: %d of %d selected", - user_id, len(filtered), len(all_tools), - ) - return filtered +__all__ = ["get_briefing_tools"] diff --git a/src/fabledassistant/services/tools.py b/src/fabledassistant/services/tools.py deleted file mode 100644 index aef2357..0000000 --- a/src/fabledassistant/services/tools.py +++ /dev/null @@ -1,2566 +0,0 @@ -"""Tool definitions and executor for LLM tool calling.""" - -import asyncio -import logging -import re -from datetime import date, datetime, timezone -from difflib import SequenceMatcher - -from fabledassistant.services.caldav import ( - is_caldav_configured, - list_calendars, -) -from fabledassistant.services.events import ( - create_event as events_create_event, - list_events as events_list_events, - search_events as events_search_events, - update_event as events_update_event, - delete_event as events_delete_event, - find_events_by_query, -) -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", - }, - "status": { - "type": "string", - "enum": ["todo", "in_progress", "done", "cancelled"], - "description": "Initial task status (default: todo)", - }, - "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", - }, - "tags": { - "type": "array", - "items": {"type": "string"}, - "description": "Tags for the task (without # prefix)", - }, - "project": { - "type": "string", - "description": "Optional project name to assign this task to. Only set this if the user explicitly named a project. Do NOT infer a project from the task content or context.", - }, - "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.", - }, - "recurrence_rule": { - "type": "object", - "description": 'Optional recurrence. Interval form: {"type":"interval","every":3,"unit":"month"} (units: day/week/month/year). Calendar form: {"type":"calendar","unit":"month","day_of_month":1}. Annual: add "month":6.', - }, - }, - "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. Only set this if the user explicitly named a project. Do NOT infer a project from the note content or context.", - }, - "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 for edits, marking tasks done, changing priority. Never use create_note for existing notes.", - "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", "cancelled"], - "description": "New task status. Use to mark a task done, start it, cancel 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)", - }, - "milestone": { - "type": "string", - "description": "Optional milestone title within the project to assign this task to (set to empty string to remove milestone)", - }, - "recurrence_rule": { - "type": "object", - "description": "Set or update recurrence (same format as create_task). Pass null to remove.", - }, - }, - "required": ["query"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "search_notes", - "description": "Search notes and tasks by semantic meaning — finds content by concept or theme even when exact keywords don't appear.", - "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 or task. Use this when the user asks to read, " - "view, or check what a particular note or task says. Returns the complete 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. " - "Always confirm with the user first — this cannot be undone." - ), - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Title or keyword to find the note to delete", - }, - "confirmed": { - "type": "boolean", - "description": "Must be true — only set after the user has explicitly confirmed they want this note deleted.", - }, - }, - "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. " - "Always confirm with the user first — this cannot be undone." - ), - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Title or keyword to find the task to delete", - }, - "confirmed": { - "type": "boolean", - "description": "Must be true — only set after the user has explicitly confirmed they want this task deleted.", - }, - }, - "required": ["query"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "list_tasks", - "description": "List tasks with optional filters. For overdue tasks, set due_before to today's date.", - "parameters": { - "type": "object", - "properties": { - "q": { - "type": "string", - "description": "Optional keyword filter — searches title and body", - }, - "status": { - "type": "array", - "items": { - "type": "string", - "enum": ["todo", "in_progress", "done", "cancelled"], - }, - "description": "Filter by one or more statuses. Omit for all.", - }, - "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 new project. Use list_projects first to check for duplicates. Only call after user has explicitly confirmed.", - "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"}, - "color": {"type": "string", "description": "Optional hex color for the project (e.g. '#6366f1')"}, - "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 title, status, description, goal, or color.", - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Project name to find"}, - "title": {"type": "string", "description": "New project title"}, - "status": {"type": "string", "enum": ["active", "completed", "archived"]}, - "description": {"type": "string"}, - "goal": {"type": "string"}, - "color": {"type": "string", "description": "Hex color (e.g. '#6366f1'), or empty string to clear"}, - }, - "required": ["query"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "create_milestone", - "description": "Create a milestone inside a project. Confirm name and project with user before calling.", - "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", "done"], "description": "New status: 'active' (in progress) or 'done' (completed)"}, - }, - "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 weather forecast. By default returns today only — use days=7 when the user " - "explicitly asks for a multi-day forecast or 'this week'. " - "Pass a city/region name to look up any location, or omit to get the user's configured home/work locations." - ), - "parameters": { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": "City/region to look up, or 'home'/'work' for saved locations. Omit for all saved.", - }, - "days": { - "type": "integer", - "description": ( - "Number of days to return (1–8). Default 1 (today only). " - "Use 7 or 8 when the user asks for a weekly forecast or multiple days." - ), - }, - }, - "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": [], - }, - }, - }, - { - "type": "function", - "function": { - "name": "add_rss_feed", - "description": "Add an RSS/Atom feed. Use when user asks to subscribe to or track a feed, blog, subreddit, or podcast.", - "parameters": { - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "The RSS/Atom feed URL to add.", - }, - "category": { - "type": "string", - "description": "Optional category label (e.g. 'news', 'tech', 'reddit'). Omit if unsure.", - }, - }, - "required": ["url"], - }, - }, - }, - { - "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", - }, - "start": { - "type": "string", - "description": "Start date (YYYY-MM-DD) or datetime (ISO 8601 with timezone offset)", - }, - "end": { - "type": "string", - "description": "Optional end date or datetime", - }, - "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", - }, - "color": { - "type": "string", - "description": "Optional hex color for the event (e.g. '#6366f1')", - }, - "all_day": { - "type": "boolean", - "description": "True for all-day events (birthdays, holidays, deadlines)", - }, - "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", - }, - "calendar_name": { - "type": "string", - "description": "Optional calendar name to create the event in. Falls back to default calendar.", - }, - "project": { - "type": "string", - "description": "Optional project name to associate this event with", - }, - }, - "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. Always use full-day UTC ranges: date_from at T00:00:00Z and date_to at T23:59:59Z for the days of interest so events stored in UTC are not missed.", - "parameters": { - "type": "object", - "properties": { - "date_from": { - "type": "string", - "description": "Start of range in ISO 8601 UTC format (e.g. 2025-01-15T00:00:00Z). Use T00:00:00Z for the start of the day.", - }, - "date_to": { - "type": "string", - "description": "End of range in ISO 8601 UTC format (e.g. 2025-01-22T23:59:59Z). Use T23:59:59Z for the end of the day.", - }, - }, - "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", - }, - "include_past": { - "type": "boolean", - "description": "Set to true to include past events in results (default: future events only)", - }, - }, - "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", - }, - "all_day": { - "type": "boolean", - "description": "Whether the event is all-day", - }, - "description": { - "type": "string", - "description": "New event description", - }, - "location": { - "type": "string", - "description": "New event location", - }, - "color": { - "type": "string", - "description": "New hex color for the event (e.g. '#6366f1')", - }, - "recurrence": { - "type": "string", - "description": "New iCalendar RRULE", - }, - "reminder_minutes": { - "type": "integer", - "description": "Reminder N minutes before the event. Pass 0 to remove an existing reminder.", - }, - }, - "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)", - }, - }, - "required": ["query"], - }, - }, - }, -] - -# CalDAV tools — only included when user has CalDAV configured -_CALDAV_TOOLS = [ - { - "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 and display images inline. Use ONLY when user explicitly asks to see or show an image or photo. Not for factual questions.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "The image search query", - } - }, - "required": ["query"], - }, - }, - } -] - - -_URL_TOOLS = [ - { - "type": "function", - "function": { - "name": "read_article", - "description": ( - "Fetch and read the full text of a web page or article from a URL. " - "Use when the user shares a URL and wants you to read it, " - "or to get the full content of a linked page. " - "Do NOT use search_web for URLs — use this tool instead." - ), - "parameters": { - "type": "object", - "properties": { - "url": {"type": "string", "description": "The URL to fetch and read"} - }, - "required": ["url"], - }, - }, - } -] - -_RAG_TOOLS = [ - { - "type": "function", - "function": { - "name": "search_projects", - "description": "Search for projects by name, description, or theme. Use this to find which project a user is referring to and get its ID for set_rag_scope.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search query — project name, topic, or theme", - } - }, - "required": ["query"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "set_rag_scope", - "description": "Change the RAG scope for this conversation. Use project_id= to scope to a project, project_id=null to scope to orphan notes only, project_id=-1 for all notes.", - "parameters": { - "type": "object", - "properties": { - "project_id": { - "type": ["integer", "null"], - "description": "Project ID to scope to, null for orphan-only, -1 for all notes", - } - }, - "required": ["project_id"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "calculate", - "description": ( - "Evaluate a mathematical expression and return the exact result. Use this for any " - "arithmetic, percentages, unit conversions, or multi-step calculations where precision " - "matters. Supports standard math operators (+, -, *, /, **, %) and all Python math " - "module functions (sqrt, log, sin, cos, floor, ceil, etc.)." - ), - "parameters": { - "type": "object", - "properties": { - "expression": { - "type": "string", - "description": "A valid Python math expression (e.g. '(450 * 1.13) / 12', 'sqrt(144)', 'log(1000, 10)')", - }, - }, - "required": ["expression"], - }, - }, - }, -] - -_ENTITY_TOOLS = [ - { - "type": "function", - "function": { - "name": "create_person", - "description": ( - "Save a person to the user's knowledge base. Use this when the user introduces " - "someone by name or asks to remember details about a person (family member, friend, " - "contact, colleague). Stored people are used to resolve names in future prompts." - ), - "parameters": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "Full name of the person"}, - "relationship": {"type": "string", "description": "Relationship to the user (e.g. daughter, dentist, colleague)"}, - "phone": {"type": "string", "description": "Phone number"}, - "email": {"type": "string", "description": "Email address"}, - "birthday": {"type": "string", "description": "Birthday in YYYY-MM-DD format"}, - "address": {"type": "string", "description": "Home or mailing address"}, - "notes": {"type": "string", "description": "Any additional free-form notes about this person"}, - }, - "required": ["name"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "update_person", - "description": ( - "Update details about a saved person. Use this when the user corrects or adds new " - "information about someone already in the knowledge base." - ), - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Name or keyword to find the person"}, - "relationship": {"type": "string", "description": "Updated relationship to the user"}, - "phone": {"type": "string", "description": "Updated phone number"}, - "email": {"type": "string", "description": "Updated email address"}, - "birthday": {"type": "string", "description": "Updated birthday in YYYY-MM-DD format"}, - "address": {"type": "string", "description": "Updated home or mailing address"}, - "notes": {"type": "string", "description": "Updated free-form notes about this person"}, - }, - "required": ["query"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "create_place", - "description": ( - "Save a named location to the user's knowledge base. Use this when the user wants " - "to remember a place (business, venue, address). Stored places are used to auto-fill " - "locations in calendar events and other prompts." - ), - "parameters": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "Name of the place (e.g. 'Dr. Smith Dental', 'Costco')"}, - "address": {"type": "string", "description": "Street address"}, - "phone": {"type": "string", "description": "Phone number"}, - "hours": {"type": "string", "description": "Opening hours (e.g. 'Mon-Fri 9am-5pm')"}, - "url": {"type": "string", "description": "Website URL"}, - "notes": {"type": "string", "description": "Any additional free-form notes about this place"}, - }, - "required": ["name"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "update_place", - "description": ( - "Update details about a saved place. Use this when the user corrects or adds new " - "information about a location already in the knowledge base." - ), - "parameters": { - "type": "object", - "properties": { - "query": {"type": "string", "description": "Name or keyword to find the place"}, - "address": {"type": "string", "description": "Updated street address"}, - "phone": {"type": "string", "description": "Updated phone number"}, - "hours": {"type": "string", "description": "Updated opening hours"}, - "url": {"type": "string", "description": "Updated website URL"}, - "notes": {"type": "string", "description": "Updated free-form notes about this place"}, - }, - "required": ["query"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "create_list", - "description": ( - "Create a named list (shopping list, to-do list, packing list, etc.). " - "Items are stored as a markdown task list. Use add_to_list to add items to an existing list." - ), - "parameters": { - "type": "object", - "properties": { - "name": {"type": "string", "description": "List name (e.g. 'Grocery List', 'Hardware Store')"}, - "items": { - "type": "array", - "items": {"type": "string"}, - "description": "Initial list items (unchecked)", - }, - "store": {"type": "string", "description": "Optional associated store or place name"}, - "tags": { - "type": "array", - "items": {"type": "string"}, - "description": "Tags for the list", - }, - }, - "required": ["name"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "add_to_list", - "description": "Add one or more items to an existing list. Items are appended as unchecked entries.", - "parameters": { - "type": "object", - "properties": { - "list_name": {"type": "string", "description": "Name of the list to add items to"}, - "items": { - "type": "array", - "items": {"type": "string"}, - "description": "Items to add", - }, - }, - "required": ["list_name", "items"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "clear_checked_items", - "description": "Remove all checked/completed items from a list, keeping only unchecked items.", - "parameters": { - "type": "object", - "properties": { - "list_name": {"type": "string", "description": "Name of the list to clear"}, - }, - "required": ["list_name"], - }, - }, - }, -] - - -_PROFILE_TOOLS = [ - { - "type": "function", - "function": { - "name": "get_profile", - "description": ( - "Retrieve the user's stored profile: name, job title, industry, expertise level, " - "preferred response style, tone, and interests. Use this when you need to personalise " - "a response and the user's profile hasn't already been injected into context." - ), - "parameters": { - "type": "object", - "properties": {}, - }, - }, - }, - { - "type": "function", - "function": { - "name": "update_profile", - "description": ( - "Update the user's stored profile when they share personal information: their name, " - "job, industry, expertise, preferred response style, tone, or interests. " - "Only set fields the user has explicitly mentioned." - ), - "parameters": { - "type": "object", - "properties": { - "display_name": {"type": "string", "description": "User's preferred display name"}, - "job_title": {"type": "string", "description": "Job title (e.g. 'Software Engineer')"}, - "industry": {"type": "string", "description": "Industry (e.g. 'Healthcare', 'Finance')"}, - "expertise_level": { - "type": "string", - "enum": ["novice", "intermediate", "expert"], - "description": "User's general expertise level", - }, - "response_style": { - "type": "string", - "enum": ["concise", "balanced", "detailed"], - "description": "Preferred response length/depth", - }, - "tone": { - "type": "string", - "enum": ["casual", "professional", "technical"], - "description": "Preferred communication tone", - }, - "interests": { - "type": "array", - "items": {"type": "string"}, - "description": "List of topics or hobbies the user is interested in", - }, - }, - }, - }, - }, -] - - -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) - tools.extend(_URL_TOOLS) - tools.extend(_RAG_TOOLS) - tools.extend(_ENTITY_TOOLS) - tools.extend(_PROFILE_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, - conv_id: int | None = None, - workspace_project_id: int | None = None, -) -> 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."} - - task_tags = arguments.get("tags", []) - if not isinstance(task_tags, list): - task_tags = [] - task_status = arguments.get("status", "todo") - note = await create_note( - user_id=user_id, - title=task_title, - body=task_body, - tags=task_tags, - status=task_status, - 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, - "milestone_id": note.milestone_id, - "parent_id": note.parent_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 - update_fields["milestone_id"] = None # clear milestone when project is cleared - - if "milestone" in arguments: - milestone_name = arguments["milestone"] - if milestone_name: - # Resolve the project context for milestone lookup - ref_project_id = update_fields.get("project_id") or note.project_id - if ref_project_id is None: - return {"success": False, "error": "Cannot assign a milestone without a project. Set project first."} - from fabledassistant.services.milestones import get_milestone_by_title as _gmbt_upd - ms = await _gmbt_upd(user_id, ref_project_id, milestone_name) - if ms is None: - return {"success": False, "error": f"Milestone '{milestone_name}' not found. Use list_milestones to see available milestones."} - update_fields["milestone_id"] = ms.id - else: - update_fields["milestone_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, - q=arguments.get("q") or None, - 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, - exclude_paused_projects=project_id is None, - 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": - start_str = arguments["start"] - end_str = arguments.get("end") - all_day = arguments.get("all_day", False) - # Parse start — accept date-only (YYYY-MM-DD) or full ISO datetime - if "T" not in start_str and " " not in start_str: - all_day = True - start_str = f"{start_str}T00:00:00" - try: - start_dt = datetime.fromisoformat(start_str) - if start_dt.tzinfo is None: - start_dt = start_dt.replace(tzinfo=timezone.utc) - except (ValueError, TypeError): - return {"success": False, "error": f"Invalid start datetime: {start_str!r}"} - end_dt = None - if end_str: - if "T" not in end_str and " " not in end_str: - end_str = f"{end_str}T00:00:00" - try: - end_dt = datetime.fromisoformat(end_str) - if end_dt.tzinfo is None: - end_dt = end_dt.replace(tzinfo=timezone.utc) - except (ValueError, TypeError): - return {"success": False, "error": f"Invalid end datetime: {end_str!r}"} - project_id = None - project_name = arguments.get("project") - if project_name: - proj = await _resolve_project(user_id, project_name) - if proj: - project_id = proj.id - event = await events_create_event( - user_id=user_id, - title=arguments.get("title", "Untitled Event"), - start_dt=start_dt, - end_dt=end_dt, - all_day=all_day, - description=arguments.get("description") or "", - location=arguments.get("location") or "", - color=arguments.get("color") or "", - recurrence=arguments.get("recurrence"), - project_id=project_id, - duration=arguments.get("duration"), - reminder_minutes=arguments.get("reminder_minutes"), - attendees=arguments.get("attendees"), - calendar_name=arguments.get("calendar_name"), - ) - return { - "success": True, - "type": "event", - "data": event.to_dict(), - } - - elif tool_name == "list_events": - try: - date_from = datetime.fromisoformat(arguments["date_from"].replace("Z", "+00:00")) - date_to = datetime.fromisoformat(arguments["date_to"].replace("Z", "+00:00")) - except (ValueError, TypeError, KeyError) as exc: - return {"success": False, "error": f"Invalid date range: {exc}"} - if date_from.tzinfo is None: - date_from = date_from.replace(tzinfo=timezone.utc) - if date_to.tzinfo is None: - date_to = date_to.replace(tzinfo=timezone.utc) - events = await events_list_events( - user_id=user_id, - date_from=date_from, - date_to=date_to, - ) - return { - "success": True, - "type": "events", - "data": { - "count": len(events), - "events": events, - }, - } - - elif tool_name == "search_events": - events = await events_search_events( - user_id=user_id, - query=arguments.get("query", ""), - include_past=arguments.get("include_past", False), - ) - return { - "success": True, - "type": "events", - "data": { - "query": arguments.get("query", ""), - "count": len(events), - "events": [e.to_dict() for e in events], - }, - } - - elif tool_name == "update_event": - query = arguments.get("query", "") - matches = await find_events_by_query(user_id=user_id, query=query) - if not matches: - return {"success": False, "error": f"No event found matching '{query}'."} - event_to_update = matches[0] - fields: dict = {} - for str_field in ("title", "description", "location", "color", "recurrence"): - if arguments.get(str_field) is not None: - fields[str_field] = arguments[str_field] - if arguments.get("all_day") is not None: - fields["all_day"] = arguments["all_day"] - if "reminder_minutes" in arguments: - rm = arguments["reminder_minutes"] - fields["reminder_minutes"] = None if rm == 0 else rm - for dt_field, key in (("start_dt", "start"), ("end_dt", "end")): - val = arguments.get(key) - if val: - try: - dt = datetime.fromisoformat(val.replace("Z", "+00:00")) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - fields[dt_field] = dt - except (ValueError, TypeError): - return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"} - updated = await events_update_event( - user_id=user_id, - event_id=event_to_update.id, - **fields, - ) - if updated is None: - return {"success": False, "error": "Event not found or update failed."} - return { - "success": True, - "type": "event_updated", - "data": updated.to_dict(), - } - - elif tool_name == "delete_event": - query = arguments.get("query", "") - matches = await find_events_by_query(user_id=user_id, query=query) - if not matches: - return {"success": False, "error": f"No event found matching '{query}'."} - event_to_delete = matches[0] - await events_delete_event(user_id=user_id, event_id=event_to_delete.id) - return { - "success": True, - "type": "event_deleted", - "data": {"id": event_to_delete.id, "title": event_to_delete.title}, - } - - 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."} - if not arguments.get("confirmed"): - return { - "success": False, - "requires_confirmation": True, - "error": f"Deleting '{note.title}' is permanent. Ask the user to confirm, then retry with confirmed=true.", - } - 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."} - if not arguments.get("confirmed"): - return { - "success": False, - "requires_confirmation": True, - "error": f"Deleting '{note.title}' is permanent. Ask the user to confirm, then retry with confirmed=true.", - } - 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 == "read_article": - from fabledassistant.services.rss import _fetch_full_article - url = arguments.get("url", "").strip() - if not url: - return {"success": False, "error": "No URL provided"} - content = await _fetch_full_article(url) - if not content: - return {"success": False, "error": f"Could not fetch article content from {url}"} - _TOOL_CONTENT_CAP = 40_000 - truncated = len(content) > _TOOL_CONTENT_CAP - return { - "success": True, - "type": "article_content", - "url": url, - "content": content[:_TOOL_CONTENT_CAP], - "truncated": truncated, - } - - 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", ""), - color=arguments.get("color") or None, - ) - 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 ("title", "status", "description", "goal"): - if k in arguments: - fields[k] = arguments[k] - if "color" in arguments: - fields["color"] = arguments["color"] or None # empty string → None (clear) - 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"![{title}](/api/images/{record.id})", - "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, - refresh_location_cache, - ) - from fabledassistant.services.briefing_pipeline import _get_temp_unit - from datetime import timezone as _tz, timedelta as _td - import json as _wx_json - location = (arguments.get("location") or "").strip() - num_days = max(1, min(8, int(arguments.get("days") or 1))) - temp_unit = await _get_temp_unit(user_id) - - def _apply_unit(days: list[dict]) -> list[dict]: - if temp_unit != "F": - return days - def _c_to_f(v: float | None) -> float | None: - return round(v * 9 / 5 + 32, 1) if v is not None else None - return [ - {**d, "temp_max": _c_to_f(d.get("temp_max")), "temp_min": _c_to_f(d.get("temp_min"))} - for d in days - ] - - _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 = _apply_unit(parse_forecast(raw))[:num_days] - return {"success": True, "data": {"locations": [{ - "location_key": "query", - "location_label": label, - "fetched_at": datetime.now(_tz.utc).isoformat(), - "days": days, - "temp_unit": temp_unit, - "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. Auto-refresh any - # row older than 6 hours so stale forecasts don't slip into - # briefings after an outage or missed scheduler run. - 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()] - now_utc = datetime.now(_tz.utc) - stale_cutoff = now_utc - _td(hours=6) - stale_keys = [] - for loc in locations: - try: - fetched = datetime.fromisoformat(loc.get("fetched_at", "")) - except Exception: - fetched = None - if fetched is None or fetched < stale_cutoff: - stale_keys.append(loc["location_key"]) - if stale_keys: - try: - from fabledassistant.services.settings import get_setting as _wx_get_setting - raw_cfg = await _wx_get_setting(user_id, "briefing_config", "{}") - cfg = _wx_json.loads(raw_cfg) if isinstance(raw_cfg, str) else (raw_cfg or {}) - cfg_locs = cfg.get("locations", {}) if isinstance(cfg, dict) else {} - for key in stale_keys: - meta = cfg_locs.get(key) or {} - if meta.get("lat") and meta.get("lon"): - try: - await refresh_location_cache( - user_id=user_id, - location_key=key, - location_label=meta.get("label", key), - lat=meta["lat"], - lon=meta["lon"], - ) - except Exception: - logger.debug("Weather refresh failed for %s", key, exc_info=True) - 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()] - except Exception: - logger.debug("Weather staleness refresh failed", exc_info=True) - # Mark each location with a stale flag so the model can hedge - # or skip instead of faithfully relaying old numbers. - stamped_locations = [] - for loc in locations: - days = _apply_unit(loc.get("days", []))[:num_days] - try: - fetched = datetime.fromisoformat(loc.get("fetched_at", "")) - age_h = (now_utc - fetched).total_seconds() / 3600.0 - except Exception: - age_h = None - stamped_locations.append({ - **loc, - "days": days, - "temp_unit": temp_unit, - "cache_age_hours": round(age_h, 1) if age_h is not None else None, - "is_stale": age_h is not None and age_h > 12.0, - }) - return {"success": True, "data": {"locations": stamped_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)}} - - elif tool_name == "add_rss_feed": - import asyncio as _asyncio - from fabledassistant.models import async_session as _async_session - from fabledassistant.models.rss_feed import RssFeed - from fabledassistant.services.rss import fetch_and_cache_feed - from sqlalchemy import select as _select - url = str(arguments.get("url", "")).strip() - if not url: - return {"error": "url is required"} - category = arguments.get("category") or None - async with _async_session() as session: - existing = await session.execute( - _select(RssFeed).where(RssFeed.user_id == user_id, RssFeed.url == url) - ) - if existing.scalars().first(): - return {"error": "Feed already added", "url": url} - feed = RssFeed(user_id=user_id, url=url, title="", category=category) - session.add(feed) - await session.commit() - await session.refresh(feed) - feed_id = feed.id - _asyncio.create_task(fetch_and_cache_feed(feed_id, url)) - return {"data": {"id": feed_id, "url": url, "message": "Feed added and fetching started."}} - - elif tool_name == "search_projects": - from difflib import SequenceMatcher - query = str(arguments.get("query", "")).lower() - from fabledassistant.services.projects import list_projects - projects = await list_projects(user_id) - scored: list[tuple[float, object]] = [] - for p in projects: - combined = f"{p.title} {p.description or ''} {p.auto_summary or ''}".lower() - base_score = SequenceMatcher(None, query, combined).ratio() - # Bonus for keyword overlap - query_words = set(query.split()) - overlap = sum(1 for w in query_words if w in combined) - score = base_score + overlap * 0.05 - scored.append((score, p)) - scored.sort(key=lambda x: x[0], reverse=True) - results = [] - for score, p in scored[:5]: - results.append({ - "id": p.id, - "title": p.title, - "summary_snippet": (p.auto_summary or p.description or "")[:200], - "score": round(score, 3), - }) - return {"type": "projects_list", "data": {"projects": results}} - - elif tool_name == "set_rag_scope": - if workspace_project_id is not None: - return {"success": False, "error": "Cannot change RAG scope in workspace view"} - if conv_id is None: - return {"success": False, "error": "No conversation context available"} - project_id = arguments.get("project_id") # None, -1, or positive int - # Validate positive project_id belongs to user - if project_id is not None and project_id != -1: - from fabledassistant.services.projects import get_project - proj = await get_project(user_id, int(project_id)) - if proj is None: - return {"success": False, "error": "Project not found"} - scope_label = proj.title - elif project_id == -1: - scope_label = "All notes" - else: - scope_label = "Orphan notes only" - from fabledassistant.services.chat import update_conversation - await update_conversation(user_id, conv_id, rag_project_id=project_id) - return {"success": True, "type": "rag_scope_set", "scope_label": scope_label} - - elif tool_name == "create_person": - name = str(arguments.get("name", "")).strip() - if not name: - return {"success": False, "error": "name is required"} - meta: dict = {} - for field in ("relationship", "phone", "email", "birthday", "address"): - val = arguments.get(field) - if val: - meta[field] = str(val) - extra_notes = arguments.get("notes", "") - body_lines = [] - if meta.get("relationship"): - body_lines.append(f"**Relationship:** {meta['relationship']}") - if meta.get("phone"): - body_lines.append(f"**Phone:** {meta['phone']}") - if meta.get("email"): - body_lines.append(f"**Email:** {meta['email']}") - if meta.get("birthday"): - body_lines.append(f"**Birthday:** {meta['birthday']}") - if meta.get("address"): - body_lines.append(f"**Address:** {meta['address']}") - if extra_notes: - body_lines.append(f"\n{extra_notes}") - note = await create_note( - user_id=user_id, - title=name, - body="\n".join(body_lines), - tags=["person"], - note_type="person", - entity_meta=meta, - ) - _schedule_embedding(note.id, user_id, name, note.body) - return {"success": True, "type": "person", "data": {"id": note.id, "name": name}} - - elif tool_name == "create_place": - name = str(arguments.get("name", "")).strip() - if not name: - return {"success": False, "error": "name is required"} - meta = {} - for field in ("address", "phone", "hours", "url"): - val = arguments.get(field) - if val: - meta[field] = str(val) - extra_notes = arguments.get("notes", "") - body_lines = [] - if meta.get("address"): - body_lines.append(f"**Address:** {meta['address']}") - if meta.get("phone"): - body_lines.append(f"**Phone:** {meta['phone']}") - if meta.get("hours"): - body_lines.append(f"**Hours:** {meta['hours']}") - if meta.get("url"): - body_lines.append(f"**Website:** {meta['url']}") - if extra_notes: - body_lines.append(f"\n{extra_notes}") - note = await create_note( - user_id=user_id, - title=name, - body="\n".join(body_lines), - tags=["place"], - note_type="place", - entity_meta=meta, - ) - _schedule_embedding(note.id, user_id, name, note.body) - return {"success": True, "type": "place", "data": {"id": note.id, "name": name}} - - elif tool_name == "update_person": - query = str(arguments.get("query", "")).strip() - if not query: - return {"success": False, "error": "query is required"} - existing, _ = await list_notes(user_id=user_id, q=query, is_task=False, limit=10) - target = next((n for n in existing if n.note_type == "person"), None) - if target is None: - return {"success": False, "error": f"No person found matching '{query}'. Use create_person to add them."} - meta = dict(target.entity_meta or {}) - for field in ("relationship", "phone", "email", "birthday", "address"): - val = arguments.get(field) - if val is not None: - meta[field] = str(val) - body_lines = [] - if meta.get("relationship"): - body_lines.append(f"**Relationship:** {meta['relationship']}") - if meta.get("phone"): - body_lines.append(f"**Phone:** {meta['phone']}") - if meta.get("email"): - body_lines.append(f"**Email:** {meta['email']}") - if meta.get("birthday"): - body_lines.append(f"**Birthday:** {meta['birthday']}") - if meta.get("address"): - body_lines.append(f"**Address:** {meta['address']}") - extra_notes = arguments.get("notes") - if extra_notes is not None: - body_lines.append(f"\n{extra_notes}") - new_body = "\n".join(body_lines) - updated = await update_note(user_id=user_id, note_id=target.id, body=new_body, entity_meta=meta) - if updated is None: - return {"success": False, "error": "Failed to update person."} - _schedule_embedding(target.id, user_id, target.title, new_body) - return {"success": True, "type": "person_updated", "data": {"id": target.id, "name": target.title}} - - elif tool_name == "update_place": - query = str(arguments.get("query", "")).strip() - if not query: - return {"success": False, "error": "query is required"} - existing, _ = await list_notes(user_id=user_id, q=query, is_task=False, limit=10) - target = next((n for n in existing if n.note_type == "place"), None) - if target is None: - return {"success": False, "error": f"No place found matching '{query}'. Use create_place to add it."} - meta = dict(target.entity_meta or {}) - for field in ("address", "phone", "hours", "url"): - val = arguments.get(field) - if val is not None: - meta[field] = str(val) - body_lines = [] - if meta.get("address"): - body_lines.append(f"**Address:** {meta['address']}") - if meta.get("phone"): - body_lines.append(f"**Phone:** {meta['phone']}") - if meta.get("hours"): - body_lines.append(f"**Hours:** {meta['hours']}") - if meta.get("url"): - body_lines.append(f"**Website:** {meta['url']}") - extra_notes = arguments.get("notes") - if extra_notes is not None: - body_lines.append(f"\n{extra_notes}") - new_body = "\n".join(body_lines) - updated = await update_note(user_id=user_id, note_id=target.id, body=new_body, entity_meta=meta) - if updated is None: - return {"success": False, "error": "Failed to update place."} - _schedule_embedding(target.id, user_id, target.title, new_body) - return {"success": True, "type": "place_updated", "data": {"id": target.id, "name": target.title}} - - elif tool_name == "create_list": - name = str(arguments.get("name", "")).strip() - if not name: - return {"success": False, "error": "name is required"} - items = arguments.get("items") or [] - if not isinstance(items, list): - items = [] - body = "\n".join(f"- [ ] {item}" for item in items if str(item).strip()) - meta = {} - store = arguments.get("store") - if store: - meta["store"] = str(store) - tags = arguments.get("tags") or [] - if not isinstance(tags, list): - tags = [] - note = await create_note( - user_id=user_id, - title=name, - body=body, - tags=tags, - note_type="list", - entity_meta=meta, - ) - _schedule_embedding(note.id, user_id, name, body) - return {"success": True, "type": "list", "data": {"id": note.id, "name": name, "item_count": len(items)}} - - elif tool_name == "add_to_list": - list_name = str(arguments.get("list_name", "")).strip() - items = arguments.get("items") or [] - if not list_name: - return {"success": False, "error": "list_name is required"} - if not isinstance(items, list) or not items: - return {"success": False, "error": "items must be a non-empty array"} - existing, _ = await list_notes(user_id=user_id, q=list_name, is_task=False, limit=10) - target = next((n for n in existing if n.note_type == "list" and n.title.lower() == list_name.lower()), None) - if target is None: - target = next((n for n in existing if n.note_type == "list"), None) - if target is None: - return {"success": False, "error": f"No list named '{list_name}' found. Use create_list to create it first."} - new_lines = "\n".join(f"- [ ] {item}" for item in items if str(item).strip()) - existing_body = (target.body or "").rstrip() - new_body = f"{existing_body}\n{new_lines}".lstrip() - await update_note(user_id=user_id, note_id=target.id, body=new_body) - _schedule_embedding(target.id, user_id, target.title, new_body) - return {"success": True, "type": "list_updated", "data": {"id": target.id, "name": target.title, "added": len(items)}} - - elif tool_name == "clear_checked_items": - list_name = str(arguments.get("list_name", "")).strip() - if not list_name: - return {"success": False, "error": "list_name is required"} - existing, _ = await list_notes(user_id=user_id, q=list_name, is_task=False, limit=10) - target = next((n for n in existing if n.note_type == "list" and n.title.lower() == list_name.lower()), None) - if target is None: - target = next((n for n in existing if n.note_type == "list"), None) - if target is None: - return {"success": False, "error": f"No list named '{list_name}' found."} - import re as _re - body = target.body or "" - cleaned = _re.sub(r"^- \[[xX]\] .+\n?", "", body, flags=_re.MULTILINE).strip() - await update_note(user_id=user_id, note_id=target.id, body=cleaned) - _schedule_embedding(target.id, user_id, target.title, cleaned) - return {"success": True, "type": "list_cleared", "data": {"id": target.id, "name": target.title}} - - elif tool_name == "get_profile": - from fabledassistant.services.user_profile import get_profile as _get_profile - profile = await _get_profile(user_id) - return { - "success": True, - "type": "profile", - "data": { - "display_name": profile.display_name or "", - "job_title": profile.job_title or "", - "industry": profile.industry or "", - "expertise_level": profile.expertise_level or "intermediate", - "response_style": profile.response_style or "balanced", - "tone": profile.tone or "casual", - "interests": profile.interests or [], - }, - } - - elif tool_name == "update_profile": - from fabledassistant.services.user_profile import update_profile as _update_profile, VALID_EXPERTISE, VALID_STYLES, VALID_TONES - data: dict = {} - for field in ("display_name", "job_title", "industry"): - val = arguments.get(field) - if val is not None: - data[field] = str(val) - expertise = arguments.get("expertise_level") - if expertise in VALID_EXPERTISE: - data["expertise_level"] = expertise - style = arguments.get("response_style") - if style in VALID_STYLES: - data["response_style"] = style - tone = arguments.get("tone") - if tone in VALID_TONES: - data["tone"] = tone - interests = arguments.get("interests") - if isinstance(interests, list): - data["interests"] = [str(i) for i in interests if str(i).strip()] - if not data: - return {"success": False, "error": "No valid fields provided to update."} - profile = await _update_profile(user_id, data) - return { - "success": True, - "type": "profile_updated", - "data": {"fields_updated": list(data.keys())}, - } - - elif tool_name == "calculate": - import math as _math - expr = str(arguments.get("expression", "")).strip() - if not expr: - return {"success": False, "error": "expression is required"} - allowed_names = {k: v for k, v in vars(_math).items() if not k.startswith("_")} - allowed_names["abs"] = abs - allowed_names["round"] = round - try: - result = eval(expr, {"__builtins__": {}}, allowed_names) # noqa: S307 - except Exception as calc_err: - return {"success": False, "error": f"Could not evaluate expression: {calc_err}"} - return {"success": True, "type": "calculation", "data": {"expression": expr, "result": result}} - - 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)} diff --git a/src/fabledassistant/services/tools/__init__.py b/src/fabledassistant/services/tools/__init__.py new file mode 100644 index 0000000..4f226de --- /dev/null +++ b/src/fabledassistant/services/tools/__init__.py @@ -0,0 +1,32 @@ +"""Tool registry package. + +Importing this package loads all tool modules (triggering ``@tool`` +decorator registration) and re-exports the public API that the rest +of the app depends on. +""" + +# Import every tool module so their @tool decorators run at import time. +# Order does not matter — registration is additive. +from fabledassistant.services.tools import ( # noqa: F401 + calendar, + entities, + notes, + profile, + projects, + rag, + rss, + tasks, + weather, + web, +) +from fabledassistant.services.tools._registry import ( + execute_tool, + get_briefing_tools, + get_tools_for_user, +) + +__all__ = [ + "execute_tool", + "get_briefing_tools", + "get_tools_for_user", +] diff --git a/src/fabledassistant/services/tools/_helpers.py b/src/fabledassistant/services/tools/_helpers.py new file mode 100644 index 0000000..491b917 --- /dev/null +++ b/src/fabledassistant/services/tools/_helpers.py @@ -0,0 +1,77 @@ +"""Shared utilities used across tool modules.""" + +from __future__ import annotations + +import asyncio +import logging +import re +from datetime import date, datetime +from difflib import SequenceMatcher + +logger = logging.getLogger(__name__) + +_PUNCT_RE = re.compile(r"[^\w\s]") + + +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 + 3. Existing title is a substring of project_name + 4. SequenceMatcher ratio >= 0.55 + """ + 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) + for p in all_p: + haystack = p.title.lower().strip() + if needle in haystack or haystack in needle: + return p + 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 + + +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 + + +def fuzzy_title_match(title: str, candidates, threshold: float = 0.82): + """Return (best_match, ratio) if any candidate's title is similar enough. + + Uses SequenceMatcher ratio. Threshold 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 diff --git a/src/fabledassistant/services/tools/_registry.py b/src/fabledassistant/services/tools/_registry.py new file mode 100644 index 0000000..3271383 --- /dev/null +++ b/src/fabledassistant/services/tools/_registry.py @@ -0,0 +1,133 @@ +"""Decorator-based tool registry. + +Each tool is registered via ``@tool(...)`` which captures the OpenAI-style +function schema and handler in one place. ``get_tools_for_user`` and +``execute_tool`` are the public API consumed by the rest of the app. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Callable, Coroutine + +from fabledassistant.config import Config +from fabledassistant.services.caldav import is_caldav_configured + +logger = logging.getLogger(__name__) + +type ToolHandler = Callable[..., Coroutine[Any, Any, dict]] + + +@dataclass(frozen=True, slots=True) +class ToolDef: + name: str + description: str + parameters: dict + handler: ToolHandler + read_only: bool = False + briefing: bool = False + requires: str | None = None + required_params: list[str] = field(default_factory=list) + + def schema(self) -> dict: + """Return the OpenAI function-calling schema dict.""" + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": { + "type": "object", + "properties": self.parameters, + **({"required": self.required_params} if self.required_params else {}), + }, + }, + } + + +_REGISTRY: dict[str, ToolDef] = {} + + +def tool( + *, + name: str, + description: str, + parameters: dict | None = None, + required: list[str] | None = None, + read_only: bool = False, + briefing: bool = False, + requires: str | None = None, +) -> Callable[[ToolHandler], ToolHandler]: + """Register an async tool handler with its schema metadata.""" + + def decorator(fn: ToolHandler) -> ToolHandler: + if name in _REGISTRY: + raise ValueError(f"Duplicate tool registration: {name}") + _REGISTRY[name] = ToolDef( + name=name, + description=description, + parameters=parameters or {}, + handler=fn, + read_only=read_only, + briefing=briefing, + requires=requires, + required_params=required or [], + ) + return fn + + return decorator + + +async def _check_requires(user_id: int, requires: str) -> bool: + if requires == "caldav": + return await is_caldav_configured(user_id) + if requires == "searxng": + return Config.searxng_enabled() + return True + + +async def get_tools_for_user(user_id: int) -> list[dict]: + """Build the tool schema list for a user based on configured integrations.""" + tools: list[dict] = [] + for td in _REGISTRY.values(): + if td.requires and not await _check_requires(user_id, td.requires): + continue + tools.append(td.schema()) + logger.debug("User %d: %d tools available", user_id, len(tools)) + return tools + + +async def get_briefing_tools(user_id: int) -> list[dict]: + """Return only the tool schemas marked ``briefing=True``.""" + all_tools = await get_tools_for_user(user_id) + names = {td.name for td in _REGISTRY.values() if td.briefing} + filtered = [t for t in all_tools if t["function"]["name"] in names] + logger.debug( + "Briefing tools for user %d: %d of %d selected", + user_id, len(filtered), len(all_tools), + ) + return filtered + + +async def execute_tool( + user_id: int, + tool_name: str, + arguments: dict, + conv_id: int | None = None, + workspace_project_id: int | None = None, +) -> dict: + """Execute a tool call and return the result.""" + td = _REGISTRY.get(tool_name) + if td is None: + return {"success": False, "error": f"Unknown tool: {tool_name}"} + try: + return await td.handler( + user_id=user_id, + arguments=arguments, + conv_id=conv_id, + workspace_project_id=workspace_project_id, + ) + except Exception as e: + logger.exception("Tool execution failed: %s", tool_name) + return {"success": False, "error": str(e)} diff --git a/src/fabledassistant/services/tools/calendar.py b/src/fabledassistant/services/tools/calendar.py new file mode 100644 index 0000000..a86edc2 --- /dev/null +++ b/src/fabledassistant/services/tools/calendar.py @@ -0,0 +1,225 @@ +"""Calendar and event tools.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from fabledassistant.services.events import ( + create_event as events_create_event, + delete_event as events_delete_event, + find_events_by_query, + list_events as events_list_events, + search_events as events_search_events, + update_event as events_update_event, +) +from fabledassistant.services.tools._helpers import resolve_project +from fabledassistant.services.tools._registry import tool + + +@tool( + 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={ + "title": {"type": "string", "description": "A descriptive event title"}, + "start": {"type": "string", "description": "Start date (YYYY-MM-DD) or datetime (ISO 8601 with timezone offset)"}, + "end": {"type": "string", "description": "Optional end date or datetime"}, + "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"}, + "color": {"type": "string", "description": "Optional hex color for the event (e.g. '#6366f1')"}, + "all_day": {"type": "boolean", "description": "True for all-day events (birthdays, holidays, deadlines)"}, + "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"}, + "calendar_name": {"type": "string", "description": "Optional calendar name to create the event in. Falls back to default calendar."}, + "project": {"type": "string", "description": "Optional project name to associate this event with"}, + }, + required=["title", "start"], +) +async def create_event_tool(*, user_id, arguments, **_ctx): + start_str = arguments["start"] + end_str = arguments.get("end") + all_day = arguments.get("all_day", False) + if "T" not in start_str and " " not in start_str: + all_day = True + start_str = f"{start_str}T00:00:00" + try: + start_dt = datetime.fromisoformat(start_str) + if start_dt.tzinfo is None: + start_dt = start_dt.replace(tzinfo=timezone.utc) + except (ValueError, TypeError): + return {"success": False, "error": f"Invalid start datetime: {start_str!r}"} + end_dt = None + if end_str: + if "T" not in end_str and " " not in end_str: + end_str = f"{end_str}T00:00:00" + try: + end_dt = datetime.fromisoformat(end_str) + if end_dt.tzinfo is None: + end_dt = end_dt.replace(tzinfo=timezone.utc) + except (ValueError, TypeError): + return {"success": False, "error": f"Invalid end datetime: {end_str!r}"} + project_id = None + project_name = arguments.get("project") + if project_name: + proj = await resolve_project(user_id, project_name) + if proj: + project_id = proj.id + event = await events_create_event( + user_id=user_id, + title=arguments.get("title", "Untitled Event"), + start_dt=start_dt, + end_dt=end_dt, + all_day=all_day, + description=arguments.get("description") or "", + location=arguments.get("location") or "", + color=arguments.get("color") or "", + recurrence=arguments.get("recurrence"), + project_id=project_id, + duration=arguments.get("duration"), + reminder_minutes=arguments.get("reminder_minutes"), + attendees=arguments.get("attendees"), + calendar_name=arguments.get("calendar_name"), + ) + return {"success": True, "type": "event", "data": event.to_dict()} + + +@tool( + name="list_events", + description="List calendar events in a date range. Use this when the user asks what events or meetings they have. Always use full-day UTC ranges: date_from at T00:00:00Z and date_to at T23:59:59Z for the days of interest so events stored in UTC are not missed.", + parameters={ + "date_from": {"type": "string", "description": "Start of range in ISO 8601 UTC format (e.g. 2025-01-15T00:00:00Z). Use T00:00:00Z for the start of the day."}, + "date_to": {"type": "string", "description": "End of range in ISO 8601 UTC format (e.g. 2025-01-22T23:59:59Z). Use T23:59:59Z for the end of the day."}, + }, + required=["date_from", "date_to"], + read_only=True, + briefing=True, +) +async def list_events_tool(*, user_id, arguments, **_ctx): + try: + date_from = datetime.fromisoformat(arguments["date_from"].replace("Z", "+00:00")) + date_to = datetime.fromisoformat(arguments["date_to"].replace("Z", "+00:00")) + except (ValueError, TypeError, KeyError) as exc: + return {"success": False, "error": f"Invalid date range: {exc}"} + if date_from.tzinfo is None: + date_from = date_from.replace(tzinfo=timezone.utc) + if date_to.tzinfo is None: + date_to = date_to.replace(tzinfo=timezone.utc) + events = await events_list_events(user_id=user_id, date_from=date_from, date_to=date_to) + return { + "success": True, + "type": "events", + "data": {"count": len(events), "events": events}, + } + + +@tool( + name="search_events", + description="Search calendar events by keyword. Use this when the user asks to find a specific event or meeting.", + parameters={ + "query": {"type": "string", "description": "Search keyword to match against event titles, locations, and descriptions"}, + "include_past": {"type": "boolean", "description": "Set to true to include past events in results (default: future events only)"}, + }, + required=["query"], + read_only=True, + briefing=True, +) +async def search_events_tool(*, user_id, arguments, **_ctx): + events = await events_search_events( + user_id=user_id, + query=arguments.get("query", ""), + include_past=arguments.get("include_past", False), + ) + return { + "success": True, + "type": "events", + "data": { + "query": arguments.get("query", ""), + "count": len(events), + "events": [e.to_dict() for e in events], + }, + } + + +@tool( + name="update_event", + description="Update an existing calendar event. Use this when the user asks to change, move, reschedule, or modify an event.", + parameters={ + "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"}, + "all_day": {"type": "boolean", "description": "Whether the event is all-day"}, + "description": {"type": "string", "description": "New event description"}, + "location": {"type": "string", "description": "New event location"}, + "color": {"type": "string", "description": "New hex color for the event (e.g. '#6366f1')"}, + "recurrence": {"type": "string", "description": "New iCalendar RRULE"}, + "reminder_minutes": {"type": "integer", "description": "Reminder N minutes before the event. Pass 0 to remove an existing reminder."}, + }, + required=["query"], +) +async def update_event_tool(*, user_id, arguments, **_ctx): + query = arguments.get("query", "") + matches = await find_events_by_query(user_id=user_id, query=query) + if not matches: + return {"success": False, "error": f"No event found matching '{query}'."} + event_to_update = matches[0] + fields: dict = {} + for str_field in ("title", "description", "location", "color", "recurrence"): + if arguments.get(str_field) is not None: + fields[str_field] = arguments[str_field] + if arguments.get("all_day") is not None: + fields["all_day"] = arguments["all_day"] + if "reminder_minutes" in arguments: + rm = arguments["reminder_minutes"] + fields["reminder_minutes"] = None if rm == 0 else rm + for dt_field, key in (("start_dt", "start"), ("end_dt", "end")): + val = arguments.get(key) + if val: + try: + dt = datetime.fromisoformat(val.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + fields[dt_field] = dt + except (ValueError, TypeError): + return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"} + updated = await events_update_event(user_id=user_id, event_id=event_to_update.id, **fields) + if updated is None: + return {"success": False, "error": "Event not found or update failed."} + return {"success": True, "type": "event_updated", "data": updated.to_dict()} + + +@tool( + name="delete_event", + description="Delete a calendar event. Use this when the user asks to cancel, remove, or delete an event.", + parameters={ + "query": {"type": "string", "description": "Search term to find the event to delete (matches against title)"}, + }, + required=["query"], +) +async def delete_event_tool(*, user_id, arguments, **_ctx): + query = arguments.get("query", "") + matches = await find_events_by_query(user_id=user_id, query=query) + if not matches: + return {"success": False, "error": f"No event found matching '{query}'."} + event_to_delete = matches[0] + await events_delete_event(user_id=user_id, event_id=event_to_delete.id) + return {"success": True, "type": "event_deleted", "data": {"id": event_to_delete.id, "title": event_to_delete.title}} + + +@tool( + name="list_calendars", + description="List all available calendars. Use this when the user asks which calendars they have.", + parameters={}, + read_only=True, + requires="caldav", +) +async def list_calendars_tool(*, user_id, arguments, **_ctx): + from fabledassistant.services.caldav import list_calendars + + calendars = await list_calendars(user_id=user_id) + return { + "success": True, + "type": "calendars", + "data": {"count": len(calendars), "calendars": calendars}, + } diff --git a/src/fabledassistant/services/tools/entities.py b/src/fabledassistant/services/tools/entities.py new file mode 100644 index 0000000..db2e9f2 --- /dev/null +++ b/src/fabledassistant/services/tools/entities.py @@ -0,0 +1,284 @@ +"""Entity tools: people, places, and lists.""" + +from __future__ import annotations + +from fabledassistant.services.notes import create_note, list_notes, update_note +from fabledassistant.services.tools._helpers import schedule_embedding +from fabledassistant.services.tools._registry import tool + + +@tool( + name="create_person", + description=( + "Save a person to the user's knowledge base. Use this when the user introduces " + "someone by name or asks to remember details about a person (family member, friend, " + "contact, colleague). Stored people are used to resolve names in future prompts." + ), + parameters={ + "name": {"type": "string", "description": "Full name of the person"}, + "relationship": {"type": "string", "description": "Relationship to the user (e.g. daughter, dentist, colleague)"}, + "phone": {"type": "string", "description": "Phone number"}, + "email": {"type": "string", "description": "Email address"}, + "birthday": {"type": "string", "description": "Birthday in YYYY-MM-DD format"}, + "address": {"type": "string", "description": "Home or mailing address"}, + "notes": {"type": "string", "description": "Any additional free-form notes about this person"}, + }, + required=["name"], +) +async def create_person_tool(*, user_id, arguments, **_ctx): + name = str(arguments.get("name", "")).strip() + if not name: + return {"success": False, "error": "name is required"} + meta: dict = {} + for field in ("relationship", "phone", "email", "birthday", "address"): + val = arguments.get(field) + if val: + meta[field] = str(val) + extra_notes = arguments.get("notes", "") + body_lines = [] + if meta.get("relationship"): + body_lines.append(f"**Relationship:** {meta['relationship']}") + if meta.get("phone"): + body_lines.append(f"**Phone:** {meta['phone']}") + if meta.get("email"): + body_lines.append(f"**Email:** {meta['email']}") + if meta.get("birthday"): + body_lines.append(f"**Birthday:** {meta['birthday']}") + if meta.get("address"): + body_lines.append(f"**Address:** {meta['address']}") + if extra_notes: + body_lines.append(f"\n{extra_notes}") + note = await create_note( + user_id=user_id, title=name, body="\n".join(body_lines), + tags=["person"], note_type="person", entity_meta=meta, + ) + schedule_embedding(note.id, user_id, name, note.body) + return {"success": True, "type": "person", "data": {"id": note.id, "name": name}} + + +@tool( + name="update_person", + description="Update details about a saved person. Use this when the user corrects or adds new information about someone already in the knowledge base.", + parameters={ + "query": {"type": "string", "description": "Name or keyword to find the person"}, + "relationship": {"type": "string", "description": "Updated relationship to the user"}, + "phone": {"type": "string", "description": "Updated phone number"}, + "email": {"type": "string", "description": "Updated email address"}, + "birthday": {"type": "string", "description": "Updated birthday in YYYY-MM-DD format"}, + "address": {"type": "string", "description": "Updated home or mailing address"}, + "notes": {"type": "string", "description": "Updated free-form notes about this person"}, + }, + required=["query"], +) +async def update_person_tool(*, user_id, arguments, **_ctx): + query = str(arguments.get("query", "")).strip() + if not query: + return {"success": False, "error": "query is required"} + existing, _ = await list_notes(user_id=user_id, q=query, is_task=False, limit=10) + target = next((n for n in existing if n.note_type == "person"), None) + if target is None: + return {"success": False, "error": f"No person found matching '{query}'. Use create_person to add them."} + meta = dict(target.entity_meta or {}) + for field in ("relationship", "phone", "email", "birthday", "address"): + val = arguments.get(field) + if val is not None: + meta[field] = str(val) + body_lines = [] + if meta.get("relationship"): + body_lines.append(f"**Relationship:** {meta['relationship']}") + if meta.get("phone"): + body_lines.append(f"**Phone:** {meta['phone']}") + if meta.get("email"): + body_lines.append(f"**Email:** {meta['email']}") + if meta.get("birthday"): + body_lines.append(f"**Birthday:** {meta['birthday']}") + if meta.get("address"): + body_lines.append(f"**Address:** {meta['address']}") + extra_notes = arguments.get("notes") + if extra_notes is not None: + body_lines.append(f"\n{extra_notes}") + new_body = "\n".join(body_lines) + updated = await update_note(user_id=user_id, note_id=target.id, body=new_body, entity_meta=meta) + if updated is None: + return {"success": False, "error": "Failed to update person."} + schedule_embedding(target.id, user_id, target.title, new_body) + return {"success": True, "type": "person_updated", "data": {"id": target.id, "name": target.title}} + + +@tool( + name="create_place", + description="Save a named location to the user's knowledge base. Use this when the user wants to remember a place (business, venue, address). Stored places are used to auto-fill locations in calendar events and other prompts.", + parameters={ + "name": {"type": "string", "description": "Name of the place (e.g. 'Dr. Smith Dental', 'Costco')"}, + "address": {"type": "string", "description": "Street address"}, + "phone": {"type": "string", "description": "Phone number"}, + "hours": {"type": "string", "description": "Opening hours (e.g. 'Mon-Fri 9am-5pm')"}, + "url": {"type": "string", "description": "Website URL"}, + "notes": {"type": "string", "description": "Any additional free-form notes about this place"}, + }, + required=["name"], +) +async def create_place_tool(*, user_id, arguments, **_ctx): + name = str(arguments.get("name", "")).strip() + if not name: + return {"success": False, "error": "name is required"} + meta = {} + for field in ("address", "phone", "hours", "url"): + val = arguments.get(field) + if val: + meta[field] = str(val) + extra_notes = arguments.get("notes", "") + body_lines = [] + if meta.get("address"): + body_lines.append(f"**Address:** {meta['address']}") + if meta.get("phone"): + body_lines.append(f"**Phone:** {meta['phone']}") + if meta.get("hours"): + body_lines.append(f"**Hours:** {meta['hours']}") + if meta.get("url"): + body_lines.append(f"**Website:** {meta['url']}") + if extra_notes: + body_lines.append(f"\n{extra_notes}") + note = await create_note( + user_id=user_id, title=name, body="\n".join(body_lines), + tags=["place"], note_type="place", entity_meta=meta, + ) + schedule_embedding(note.id, user_id, name, note.body) + return {"success": True, "type": "place", "data": {"id": note.id, "name": name}} + + +@tool( + name="update_place", + description="Update details about a saved place. Use this when the user corrects or adds new information about a location already in the knowledge base.", + parameters={ + "query": {"type": "string", "description": "Name or keyword to find the place"}, + "address": {"type": "string", "description": "Updated street address"}, + "phone": {"type": "string", "description": "Updated phone number"}, + "hours": {"type": "string", "description": "Updated opening hours"}, + "url": {"type": "string", "description": "Updated website URL"}, + "notes": {"type": "string", "description": "Updated free-form notes about this place"}, + }, + required=["query"], +) +async def update_place_tool(*, user_id, arguments, **_ctx): + query = str(arguments.get("query", "")).strip() + if not query: + return {"success": False, "error": "query is required"} + existing, _ = await list_notes(user_id=user_id, q=query, is_task=False, limit=10) + target = next((n for n in existing if n.note_type == "place"), None) + if target is None: + return {"success": False, "error": f"No place found matching '{query}'. Use create_place to add it."} + meta = dict(target.entity_meta or {}) + for field in ("address", "phone", "hours", "url"): + val = arguments.get(field) + if val is not None: + meta[field] = str(val) + body_lines = [] + if meta.get("address"): + body_lines.append(f"**Address:** {meta['address']}") + if meta.get("phone"): + body_lines.append(f"**Phone:** {meta['phone']}") + if meta.get("hours"): + body_lines.append(f"**Hours:** {meta['hours']}") + if meta.get("url"): + body_lines.append(f"**Website:** {meta['url']}") + extra_notes = arguments.get("notes") + if extra_notes is not None: + body_lines.append(f"\n{extra_notes}") + new_body = "\n".join(body_lines) + updated = await update_note(user_id=user_id, note_id=target.id, body=new_body, entity_meta=meta) + if updated is None: + return {"success": False, "error": "Failed to update place."} + schedule_embedding(target.id, user_id, target.title, new_body) + return {"success": True, "type": "place_updated", "data": {"id": target.id, "name": target.title}} + + +@tool( + name="create_list", + description="Create a named list (shopping list, to-do list, packing list, etc.). Items are stored as a markdown task list. Use add_to_list to add items to an existing list.", + parameters={ + "name": {"type": "string", "description": "List name (e.g. 'Grocery List', 'Hardware Store')"}, + "items": {"type": "array", "items": {"type": "string"}, "description": "Initial list items (unchecked)"}, + "store": {"type": "string", "description": "Optional associated store or place name"}, + "tags": {"type": "array", "items": {"type": "string"}, "description": "Tags for the list"}, + }, + required=["name"], +) +async def create_list_tool(*, user_id, arguments, **_ctx): + name = str(arguments.get("name", "")).strip() + if not name: + return {"success": False, "error": "name is required"} + items = arguments.get("items") or [] + if not isinstance(items, list): + items = [] + body = "\n".join(f"- [ ] {item}" for item in items if str(item).strip()) + meta = {} + store = arguments.get("store") + if store: + meta["store"] = str(store) + tags = arguments.get("tags") or [] + if not isinstance(tags, list): + tags = [] + note = await create_note( + user_id=user_id, title=name, body=body, + tags=tags, note_type="list", entity_meta=meta, + ) + schedule_embedding(note.id, user_id, name, body) + return {"success": True, "type": "list", "data": {"id": note.id, "name": name, "item_count": len(items)}} + + +@tool( + name="add_to_list", + description="Add one or more items to an existing list. Items are appended as unchecked entries.", + parameters={ + "list_name": {"type": "string", "description": "Name of the list to add items to"}, + "items": {"type": "array", "items": {"type": "string"}, "description": "Items to add"}, + }, + required=["list_name", "items"], +) +async def add_to_list_tool(*, user_id, arguments, **_ctx): + list_name = str(arguments.get("list_name", "")).strip() + items = arguments.get("items") or [] + if not list_name: + return {"success": False, "error": "list_name is required"} + if not isinstance(items, list) or not items: + return {"success": False, "error": "items must be a non-empty array"} + existing, _ = await list_notes(user_id=user_id, q=list_name, is_task=False, limit=10) + target = next((n for n in existing if n.note_type == "list" and n.title.lower() == list_name.lower()), None) + if target is None: + target = next((n for n in existing if n.note_type == "list"), None) + if target is None: + return {"success": False, "error": f"No list named '{list_name}' found. Use create_list to create it first."} + new_lines = "\n".join(f"- [ ] {item}" for item in items if str(item).strip()) + existing_body = (target.body or "").rstrip() + new_body = f"{existing_body}\n{new_lines}".lstrip() + await update_note(user_id=user_id, note_id=target.id, body=new_body) + schedule_embedding(target.id, user_id, target.title, new_body) + return {"success": True, "type": "list_updated", "data": {"id": target.id, "name": target.title, "added": len(items)}} + + +@tool( + name="clear_checked_items", + description="Remove all checked/completed items from a list, keeping only unchecked items.", + parameters={ + "list_name": {"type": "string", "description": "Name of the list to clear"}, + }, + required=["list_name"], +) +async def clear_checked_items_tool(*, user_id, arguments, **_ctx): + import re as _re + + list_name = str(arguments.get("list_name", "")).strip() + if not list_name: + return {"success": False, "error": "list_name is required"} + existing, _ = await list_notes(user_id=user_id, q=list_name, is_task=False, limit=10) + target = next((n for n in existing if n.note_type == "list" and n.title.lower() == list_name.lower()), None) + if target is None: + target = next((n for n in existing if n.note_type == "list"), None) + if target is None: + return {"success": False, "error": f"No list named '{list_name}' found."} + body = target.body or "" + cleaned = _re.sub(r"^- \[[xX]\] .+\n?", "", body, flags=_re.MULTILINE).strip() + await update_note(user_id=user_id, note_id=target.id, body=cleaned) + schedule_embedding(target.id, user_id, target.title, cleaned) + return {"success": True, "type": "list_cleared", "data": {"id": target.id, "name": target.title}} diff --git a/src/fabledassistant/services/tools/notes.py b/src/fabledassistant/services/tools/notes.py new file mode 100644 index 0000000..59b2cf2 --- /dev/null +++ b/src/fabledassistant/services/tools/notes.py @@ -0,0 +1,384 @@ +"""Note tools: create, update, get, list, delete, search.""" + +from __future__ import annotations + +import logging + +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 +from fabledassistant.services.tools._helpers import ( + _PUNCT_RE, + fuzzy_title_match, + parse_due_date, + resolve_project, + schedule_embedding, +) +from fabledassistant.services.tools._registry import tool + +logger = logging.getLogger(__name__) + + +@tool( + 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={ + "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. Only set this if the user explicitly named a project. Do NOT infer a project from the note content or context."}, + "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"], +) +async def create_note_tool(*, user_id, arguments, **_ctx): + 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 = [] + + 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, + } + + +@tool( + name="update_note", + description="Update an existing note or task — content, title, status, priority, or due date. Use for edits, marking tasks done, changing priority. Never use create_note for existing notes.", + parameters={ + "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", "cancelled"], "description": "New task status. Use to mark a task done, start it, cancel 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)"}, + "milestone": {"type": "string", "description": "Optional milestone title within the project to assign this task to (set to empty string to remove milestone)"}, + "recurrence_rule": {"type": "object", "description": "Set or update recurrence (same format as create_task). Pass null to remove."}, + }, + required=["query"], +) +async def update_note_tool(*, user_id, arguments, **_ctx): + query = arguments.get("query", "") + new_body = arguments.get("body", "") + new_title = arguments.get("title") + mode = arguments.get("mode", "replace") + + 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) + 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] + + 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 + update_fields["milestone_id"] = None + + if "milestone" in arguments: + milestone_name = arguments["milestone"] + if milestone_name: + ref_project_id = update_fields.get("project_id") or note.project_id + if ref_project_id is None: + return {"success": False, "error": "Cannot assign a milestone without a project. Set project first."} + from fabledassistant.services.milestones import get_milestone_by_title as _gmbt_upd + ms = await _gmbt_upd(user_id, ref_project_id, milestone_name) + if ms is None: + return {"success": False, "error": f"Milestone '{milestone_name}' not found. Use list_milestones to see available milestones."} + update_fields["milestone_id"] = ms.id + else: + update_fields["milestone_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, + } + + +@tool( + name="search_notes", + description="Search notes and tasks by semantic meaning — finds content by concept or theme even when exact keywords don't appear.", + parameters={ + "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"], + read_only=True, + briefing=True, +) +async def search_notes_tool(*, user_id, arguments, **_ctx): + 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 + + 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] = {} + + 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] + "\u2026") 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) + + 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] + "\u2026") 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}, + } + + +@tool( + name="get_note", + description="Retrieve the full content of a specific note or task. Use this when the user asks to read, view, or check what a particular note or task says. Returns the complete body.", + parameters={ + "query": {"type": "string", "description": "Title or keyword to identify the note"}, + }, + required=["query"], + read_only=True, + briefing=True, +) +async def get_note_tool(*, user_id, arguments, **_ctx): + 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 []}, + } + + +@tool( + 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={ + "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)"}, + }, + read_only=True, + briefing=True, +) +async def list_notes_tool(*, user_id, arguments, **_ctx): + 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}, + } + + +@tool( + name="delete_note", + description="Delete a note permanently. Use ONLY when the user explicitly asks to delete or remove a note. Always confirm with the user first — this cannot be undone.", + parameters={ + "query": {"type": "string", "description": "Title or keyword to find the note to delete"}, + "confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this note deleted."}, + }, + required=["query"], +) +async def delete_note_tool(*, user_id, arguments, **_ctx): + 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."} + if not arguments.get("confirmed"): + return { + "success": False, + "requires_confirmation": True, + "error": f"Deleting '{note.title}' is permanent. Ask the user to confirm, then retry with confirmed=true.", + } + 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}} diff --git a/src/fabledassistant/services/tools/profile.py b/src/fabledassistant/services/tools/profile.py new file mode 100644 index 0000000..d7796ab --- /dev/null +++ b/src/fabledassistant/services/tools/profile.py @@ -0,0 +1,77 @@ +"""User profile tools.""" + +from __future__ import annotations + +from fabledassistant.services.tools._registry import tool + + +@tool( + name="get_profile", + description=( + "Retrieve the user's stored profile: name, job title, industry, expertise level, " + "preferred response style, tone, and interests. Use this when you need to personalise " + "a response and the user's profile hasn't already been injected into context." + ), + parameters={}, + read_only=True, +) +async def get_profile_tool(*, user_id, arguments, **_ctx): + from fabledassistant.services.user_profile import get_profile as _get_profile + + profile = await _get_profile(user_id) + return { + "success": True, + "type": "profile", + "data": { + "display_name": profile.display_name or "", + "job_title": profile.job_title or "", + "industry": profile.industry or "", + "expertise_level": profile.expertise_level or "intermediate", + "response_style": profile.response_style or "balanced", + "tone": profile.tone or "casual", + "interests": profile.interests or [], + }, + } + + +@tool( + name="update_profile", + description=( + "Update the user's stored profile when they share personal information: their name, " + "job, industry, expertise, preferred response style, tone, or interests. " + "Only set fields the user has explicitly mentioned." + ), + parameters={ + "display_name": {"type": "string", "description": "User's preferred display name"}, + "job_title": {"type": "string", "description": "Job title (e.g. 'Software Engineer')"}, + "industry": {"type": "string", "description": "Industry (e.g. 'Healthcare', 'Finance')"}, + "expertise_level": {"type": "string", "enum": ["novice", "intermediate", "expert"], "description": "User's general expertise level"}, + "response_style": {"type": "string", "enum": ["concise", "balanced", "detailed"], "description": "Preferred response length/depth"}, + "tone": {"type": "string", "enum": ["casual", "professional", "technical"], "description": "Preferred communication tone"}, + "interests": {"type": "array", "items": {"type": "string"}, "description": "List of topics or hobbies the user is interested in"}, + }, +) +async def update_profile_tool(*, user_id, arguments, **_ctx): + from fabledassistant.services.user_profile import VALID_EXPERTISE, VALID_STYLES, VALID_TONES, update_profile as _update_profile + + data: dict = {} + for field in ("display_name", "job_title", "industry"): + val = arguments.get(field) + if val is not None: + data[field] = str(val) + expertise = arguments.get("expertise_level") + if expertise in VALID_EXPERTISE: + data["expertise_level"] = expertise + style = arguments.get("response_style") + if style in VALID_STYLES: + data["response_style"] = style + tone = arguments.get("tone") + if tone in VALID_TONES: + data["tone"] = tone + interests = arguments.get("interests") + if isinstance(interests, list): + data["interests"] = [str(i) for i in interests if str(i).strip()] + if not data: + return {"success": False, "error": "No valid fields provided to update."} + await _update_profile(user_id, data) + return {"success": True, "type": "profile_updated", "data": {"fields_updated": list(data.keys())}} diff --git a/src/fabledassistant/services/tools/projects.py b/src/fabledassistant/services/tools/projects.py new file mode 100644 index 0000000..83aefcb --- /dev/null +++ b/src/fabledassistant/services/tools/projects.py @@ -0,0 +1,258 @@ +"""Project and milestone tools.""" + +from __future__ import annotations + +from fabledassistant.services.tools._helpers import fuzzy_title_match, resolve_project +from fabledassistant.services.tools._registry import tool + + +@tool( + name="create_project", + description="Create a new project. Use list_projects first to check for duplicates. Only call after user has explicitly confirmed.", + parameters={ + "title": {"type": "string", "description": "Project title"}, + "description": {"type": "string", "description": "Brief description"}, + "goal": {"type": "string", "description": "The goal or desired outcome"}, + "color": {"type": "string", "description": "Optional hex color for the project (e.g. '#6366f1')"}, + "confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this project created."}, + }, + required=["title", "confirmed"], +) +async def create_project_tool(*, user_id, arguments, **_ctx): + 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") + 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) + 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", ""), + color=arguments.get("color") or None, + ) + return {"success": True, "type": "project", "data": project.to_dict()} + + +@tool( + name="list_projects", + description="List the user's projects. Use when asked about projects, initiatives, or campaigns.", + parameters={ + "status": {"type": "string", "enum": ["active", "completed", "archived"], "description": "Filter by status (omit for all)"}, + }, + read_only=True, + briefing=True, +) +async def list_projects_tool(*, user_id, arguments, **_ctx): + 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]}, + } + + +@tool( + name="get_project", + description="Get details and summary of a specific project.", + parameters={ + "query": {"type": "string", "description": "Project name or keyword to find it"}, + }, + required=["query"], + read_only=True, + briefing=True, +) +async def get_project_tool(*, user_id, arguments, **_ctx): + 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} + + +@tool( + name="update_project", + description="Update a project's title, status, description, goal, or color.", + parameters={ + "query": {"type": "string", "description": "Project name to find"}, + "title": {"type": "string", "description": "New project title"}, + "status": {"type": "string", "enum": ["active", "completed", "archived"]}, + "description": {"type": "string"}, + "goal": {"type": "string"}, + "color": {"type": "string", "description": "Hex color (e.g. '#6366f1'), or empty string to clear"}, + }, + required=["query"], +) +async def update_project_tool(*, user_id, arguments, **_ctx): + 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 ("title", "status", "description", "goal"): + if k in arguments: + fields[k] = arguments[k] + if "color" in arguments: + fields["color"] = arguments["color"] or None + updated = await _up(user_id, project.id, **fields) + return {"success": True, "type": "project_updated", "data": updated.to_dict()} + + +@tool( + name="search_projects", + description="Search for projects by name, description, or theme. Use this to find which project a user is referring to and get its ID for set_rag_scope.", + parameters={ + "query": {"type": "string", "description": "Search query — project name, topic, or theme"}, + }, + required=["query"], + read_only=True, + briefing=True, +) +async def search_projects_tool(*, user_id, arguments, **_ctx): + from difflib import SequenceMatcher + + from fabledassistant.services.projects import list_projects + + query = str(arguments.get("query", "")).lower() + projects = await list_projects(user_id) + scored: list[tuple[float, object]] = [] + for p in projects: + combined = f"{p.title} {p.description or ''} {p.auto_summary or ''}".lower() + base_score = SequenceMatcher(None, query, combined).ratio() + query_words = set(query.split()) + overlap = sum(1 for w in query_words if w in combined) + score = base_score + overlap * 0.05 + scored.append((score, p)) + scored.sort(key=lambda x: x[0], reverse=True) + results = [] + for score, p in scored[:5]: + results.append({ + "id": p.id, + "title": p.title, + "summary_snippet": (p.auto_summary or p.description or "")[:200], + "score": round(score, 3), + }) + return {"type": "projects_list", "data": {"projects": results}} + + +@tool( + name="create_milestone", + description="Create a milestone inside a project. Confirm name and project with user before calling.", + parameters={ + "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"], +) +async def create_milestone_tool(*, user_id, arguments, **_ctx): + from fabledassistant.services.milestones import create_milestone as _cm, get_milestone_by_title as _gmbt2, list_milestones as _lms + + project_name = arguments.get("project", "") + ms_title = 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}\" 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."} + 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()} + + +@tool( + name="update_milestone", + description="Update the title, description, or status of an existing milestone.", + parameters={ + "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", "done"], "description": "New status: 'active' (in progress) or 'done' (completed)"}, + }, + required=["project", "milestone"], +) +async def update_milestone_tool(*, user_id, arguments, **_ctx): + 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()} + + +@tool( + name="list_milestones", + description="List milestones for a project with their progress percentages.", + parameters={ + "project": {"type": "string", "description": "Project name to look up"}, + }, + required=["project"], + read_only=True, + briefing=True, +) +async def list_milestones_tool(*, user_id, arguments, **_ctx): + 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}, + } diff --git a/src/fabledassistant/services/tools/rag.py b/src/fabledassistant/services/tools/rag.py new file mode 100644 index 0000000..d946c75 --- /dev/null +++ b/src/fabledassistant/services/tools/rag.py @@ -0,0 +1,63 @@ +"""RAG scope and calculation tools.""" + +from __future__ import annotations + +from fabledassistant.services.tools._registry import tool + + +@tool( + name="set_rag_scope", + description="Change the RAG scope for this conversation. Use project_id= to scope to a project, project_id=null to scope to orphan notes only, project_id=-1 for all notes.", + parameters={ + "project_id": {"type": ["integer", "null"], "description": "Project ID to scope to, null for orphan-only, -1 for all notes"}, + }, + required=["project_id"], +) +async def set_rag_scope_tool(*, user_id, arguments, conv_id=None, workspace_project_id=None, **_ctx): + if workspace_project_id is not None: + return {"success": False, "error": "Cannot change RAG scope in workspace view"} + if conv_id is None: + return {"success": False, "error": "No conversation context available"} + project_id = arguments.get("project_id") + if project_id is not None and project_id != -1: + from fabledassistant.services.projects import get_project + proj = await get_project(user_id, int(project_id)) + if proj is None: + return {"success": False, "error": "Project not found"} + scope_label = proj.title + elif project_id == -1: + scope_label = "All notes" + else: + scope_label = "Orphan notes only" + from fabledassistant.services.chat import update_conversation + await update_conversation(user_id, conv_id, rag_project_id=project_id) + return {"success": True, "type": "rag_scope_set", "scope_label": scope_label} + + +@tool( + name="calculate", + description=( + "Evaluate a mathematical expression and return the exact result. Use this for any " + "arithmetic, percentages, unit conversions, or multi-step calculations where precision " + "matters. Supports standard math operators (+, -, *, /, **, %) and all Python math " + "module functions (sqrt, log, sin, cos, floor, ceil, etc.)." + ), + parameters={ + "expression": {"type": "string", "description": "A valid Python math expression (e.g. '(450 * 1.13) / 12', 'sqrt(144)', 'log(1000, 10)')"}, + }, + required=["expression"], +) +async def calculate_tool(*, user_id, arguments, **_ctx): + import math as _math + + expr = str(arguments.get("expression", "")).strip() + if not expr: + return {"success": False, "error": "expression is required"} + allowed_names = {k: v for k, v in vars(_math).items() if not k.startswith("_")} + allowed_names["abs"] = abs + allowed_names["round"] = round + try: + result = eval(expr, {"__builtins__": {}}, allowed_names) # noqa: S307 + except Exception as calc_err: + return {"success": False, "error": f"Could not evaluate expression: {calc_err}"} + return {"success": True, "type": "calculation", "data": {"expression": expr, "result": result}} diff --git a/src/fabledassistant/services/tools/rss.py b/src/fabledassistant/services/tools/rss.py new file mode 100644 index 0000000..434d301 --- /dev/null +++ b/src/fabledassistant/services/tools/rss.py @@ -0,0 +1,99 @@ +"""RSS and article tools.""" + +from __future__ import annotations + +import logging + +from fabledassistant.services.tools._registry import tool + +logger = logging.getLogger(__name__) + + +@tool( + 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={ + "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."}, + }, + read_only=True, + briefing=True, +) +async def get_rss_items_tool(*, user_id, arguments, **_ctx): + 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)}} + + +@tool( + name="add_rss_feed", + description="Add an RSS/Atom feed. Use when user asks to subscribe to or track a feed, blog, subreddit, or podcast.", + parameters={ + "url": {"type": "string", "description": "The RSS/Atom feed URL to add."}, + "category": {"type": "string", "description": "Optional category label (e.g. 'news', 'tech', 'reddit'). Omit if unsure."}, + }, + required=["url"], +) +async def add_rss_feed_tool(*, user_id, arguments, **_ctx): + import asyncio as _asyncio + + from sqlalchemy import select as _select + + from fabledassistant.models import async_session as _async_session + from fabledassistant.models.rss_feed import RssFeed + from fabledassistant.services.rss import fetch_and_cache_feed + + url = str(arguments.get("url", "")).strip() + if not url: + return {"error": "url is required"} + category = arguments.get("category") or None + async with _async_session() as session: + existing = await session.execute( + _select(RssFeed).where(RssFeed.user_id == user_id, RssFeed.url == url) + ) + if existing.scalars().first(): + return {"error": "Feed already added", "url": url} + feed = RssFeed(user_id=user_id, url=url, title="", category=category) + session.add(feed) + await session.commit() + await session.refresh(feed) + feed_id = feed.id + _asyncio.create_task(fetch_and_cache_feed(feed_id, url)) + return {"data": {"id": feed_id, "url": url, "message": "Feed added and fetching started."}} + + +@tool( + name="read_article", + description=( + "Fetch and read the full text of a web page or article from a URL. " + "Use when the user shares a URL and wants you to read it, " + "or to get the full content of a linked page. " + "Do NOT use search_web for URLs — use this tool instead." + ), + parameters={ + "url": {"type": "string", "description": "The URL to fetch and read"}, + }, + required=["url"], + read_only=True, + briefing=True, +) +async def read_article_tool(*, user_id, arguments, **_ctx): + from fabledassistant.services.rss import _fetch_full_article + + url = arguments.get("url", "").strip() + if not url: + return {"success": False, "error": "No URL provided"} + content = await _fetch_full_article(url) + if not content: + return {"success": False, "error": f"Could not fetch article content from {url}"} + _TOOL_CONTENT_CAP = 40_000 + truncated = len(content) > _TOOL_CONTENT_CAP + return { + "success": True, + "type": "article_content", + "url": url, + "content": content[:_TOOL_CONTENT_CAP], + "truncated": truncated, + } diff --git a/src/fabledassistant/services/tools/tasks.py b/src/fabledassistant/services/tools/tasks.py new file mode 100644 index 0000000..ca155bb --- /dev/null +++ b/src/fabledassistant/services/tools/tasks.py @@ -0,0 +1,253 @@ +"""Task tools: create, list, delete, log work.""" + +from __future__ import annotations + +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 +from fabledassistant.services.tools._helpers import ( + _PUNCT_RE, + fuzzy_title_match, + parse_due_date, + resolve_project, + schedule_embedding, +) +from fabledassistant.services.tools._registry import tool + + +@tool( + 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={ + "title": {"type": "string", "description": "The task title"}, + "body": {"type": "string", "description": "Optional task description or details"}, + "status": {"type": "string", "enum": ["todo", "in_progress", "done", "cancelled"], "description": "Initial task status (default: todo)"}, + "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"}, + "tags": {"type": "array", "items": {"type": "string"}, "description": "Tags for the task (without # prefix)"}, + "project": {"type": "string", "description": "Optional project name to assign this task to. Only set this if the user explicitly named a project. Do NOT infer a project from the task content or context."}, + "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."}, + "recurrence_rule": {"type": "object", "description": 'Optional recurrence. Interval form: {"type":"interval","every":3,"unit":"month"} (units: day/week/month/year). Calendar form: {"type":"calendar","unit":"month","day_of_month":1}. Annual: add "month":6.'}, + }, + required=["title"], +) +async def create_task_tool(*, user_id, arguments, **_ctx): + 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 = "" + + 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."} + + task_tags = arguments.get("tags", []) + if not isinstance(task_tags, list): + task_tags = [] + task_status = arguments.get("status", "todo") + note = await create_note( + user_id=user_id, + title=task_title, + body=task_body, + tags=task_tags, + status=task_status, + 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, + "milestone_id": note.milestone_id, + "parent_id": note.parent_id, + }, + "suggested_tags": suggested, + } + + +@tool( + name="list_tasks", + description="List tasks with optional filters. For overdue tasks, set due_before to today's date.", + parameters={ + "q": {"type": "string", "description": "Optional keyword filter — searches title and body"}, + "status": {"type": "array", "items": {"type": "string", "enum": ["todo", "in_progress", "done", "cancelled"]}, "description": "Filter by one or more statuses. Omit for all."}, + "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)"}, + }, + read_only=True, + briefing=True, +) +async def list_tasks_tool(*, user_id, arguments, **_ctx): + 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: + 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, + q=arguments.get("q") or None, + 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, + exclude_paused_projects=project_id is None, + 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}, + } + + +@tool( + name="delete_task", + description="Delete a task permanently. Use ONLY when the user explicitly asks to delete or remove a task. Always confirm with the user first — this cannot be undone.", + parameters={ + "query": {"type": "string", "description": "Title or keyword to find the task to delete"}, + "confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this task deleted."}, + }, + required=["query"], +) +async def delete_task_tool(*, user_id, arguments, **_ctx): + 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."} + if not arguments.get("confirmed"): + return { + "success": False, + "requires_confirmation": True, + "error": f"Deleting '{note.title}' is permanent. Ask the user to confirm, then retry with confirmed=true.", + } + 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}} + + +@tool( + 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={ + "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"], +) +async def log_work_tool(*, user_id, arguments, **_ctx): + 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 + 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} diff --git a/src/fabledassistant/services/tools/weather.py b/src/fabledassistant/services/tools/weather.py new file mode 100644 index 0000000..7fbda5f --- /dev/null +++ b/src/fabledassistant/services/tools/weather.py @@ -0,0 +1,122 @@ +"""Weather tool.""" + +from __future__ import annotations + +import json as _wx_json +import logging +from datetime import datetime, timedelta, timezone + +from fabledassistant.services.tools._registry import tool + +logger = logging.getLogger(__name__) + + +@tool( + name="get_weather", + description=( + "Get the weather forecast. By default returns today only — use days=7 when the user " + "explicitly asks for a multi-day forecast or 'this week'. " + "Pass a city/region name to look up any location, or omit to get the user's configured home/work locations." + ), + parameters={ + "location": {"type": "string", "description": "City/region to look up, or 'home'/'work' for saved locations. Omit for all saved."}, + "days": {"type": "integer", "description": "Number of days to return (1–8). Default 1 (today only). Use 7 or 8 when the user asks for a weekly forecast or multiple days."}, + }, + read_only=True, + briefing=True, +) +async def get_weather_tool(*, user_id, arguments, **_ctx): + from fabledassistant.services.briefing_pipeline import _get_temp_unit + from fabledassistant.services.weather import ( + _fetch_open_meteo, + geocode, + get_cached_weather, + parse_forecast, + refresh_location_cache, + ) + + location = (arguments.get("location") or "").strip() + num_days = max(1, min(8, int(arguments.get("days") or 1))) + temp_unit = await _get_temp_unit(user_id) + + def _apply_unit(days: list[dict]) -> list[dict]: + if temp_unit != "F": + return days + def _c_to_f(v: float | None) -> float | None: + return round(v * 9 / 5 + 32, 1) if v is not None else None + return [ + {**d, "temp_max": _c_to_f(d.get("temp_max")), "temp_min": _c_to_f(d.get("temp_min"))} + for d in days + ] + + _known = {"home", "work", "all", ""} + if location.lower() not in _known: + try: + lat, lon, label = await geocode(location) + raw = await _fetch_open_meteo(lat, lon) + days = _apply_unit(parse_forecast(raw))[:num_days] + return {"success": True, "data": {"locations": [{ + "location_key": "query", + "location_label": label, + "fetched_at": datetime.now(timezone.utc).isoformat(), + "days": days, + "temp_unit": temp_unit, + "changes_since_last_fetch": [], + }]}} + except Exception as e: + return {"success": False, "error": f"Could not get weather for '{location}': {e}"} + + 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()] + now_utc = datetime.now(timezone.utc) + stale_cutoff = now_utc - timedelta(hours=6) + stale_keys = [] + for loc in locations: + try: + fetched = datetime.fromisoformat(loc.get("fetched_at", "")) + except Exception: + fetched = None + if fetched is None or fetched < stale_cutoff: + stale_keys.append(loc["location_key"]) + if stale_keys: + try: + from fabledassistant.services.settings import get_setting as _wx_get_setting + raw_cfg = await _wx_get_setting(user_id, "briefing_config", "{}") + cfg = _wx_json.loads(raw_cfg) if isinstance(raw_cfg, str) else (raw_cfg or {}) + cfg_locs = cfg.get("locations", {}) if isinstance(cfg, dict) else {} + for key in stale_keys: + meta = cfg_locs.get(key) or {} + if meta.get("lat") and meta.get("lon"): + try: + await refresh_location_cache( + user_id=user_id, + location_key=key, + location_label=meta.get("label", key), + lat=meta["lat"], + lon=meta["lon"], + ) + except Exception: + logger.debug("Weather refresh failed for %s", key, exc_info=True) + 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()] + except Exception: + logger.debug("Weather staleness refresh failed", exc_info=True) + + stamped_locations = [] + for loc in locations: + days = _apply_unit(loc.get("days", []))[:num_days] + try: + fetched = datetime.fromisoformat(loc.get("fetched_at", "")) + age_h = (now_utc - fetched).total_seconds() / 3600.0 + except Exception: + age_h = None + stamped_locations.append({ + **loc, + "days": days, + "temp_unit": temp_unit, + "cache_age_hours": round(age_h, 1) if age_h is not None else None, + "is_stale": age_h is not None and age_h > 12.0, + }) + return {"success": True, "data": {"locations": stamped_locations}} diff --git a/src/fabledassistant/services/tools/web.py b/src/fabledassistant/services/tools/web.py new file mode 100644 index 0000000..eb2a28e --- /dev/null +++ b/src/fabledassistant/services/tools/web.py @@ -0,0 +1,117 @@ +"""Web search, research, and image tools (require SearXNG).""" + +from __future__ import annotations + +import logging + +from fabledassistant.services.tools._registry import tool + +logger = logging.getLogger(__name__) + + +@tool( + 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={ + "query": {"type": "string", "description": "The search query"}, + }, + required=["query"], + requires="searxng", +) +async def search_web_tool(*, user_id, arguments, **_ctx): + 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)}, + } + + +@tool( + 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={ + "topic": {"type": "string", "description": "The topic or question to research"}, + }, + required=["topic"], + requires="searxng", +) +async def research_topic_tool(*, user_id, arguments, **_ctx): + # 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."} + + +@tool( + name="search_images", + description="Search and display images inline. Use ONLY when user explicitly asks to see or show an image or photo. Not for factual questions.", + parameters={ + "query": {"type": "string", "description": "The image search query"}, + }, + required=["query"], + requires="searxng", +) +async def search_images_tool(*, user_id, arguments, **_ctx): + 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}'"} + 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"![{title}](/api/images/{record.id})", + "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." + ), + }, + }