refactor(tools): decorator-based tool registry replaces monolithic tools.py
Split 2566-line tools.py into a tools/ package with @tool decorator registration. Each tool's schema, metadata, and implementation live together. Briefing eligibility is now a briefing=True flag instead of a separate frozenset allowlist. Conditional inclusion (CalDAV, SearXNG) uses requires= metadata. Public API (get_tools_for_user, execute_tool) unchanged. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,62 +1,9 @@
|
|||||||
"""
|
"""Briefing tool subset — delegates to the registry's ``briefing=True`` filter.
|
||||||
Curated read-only tool subset for agentic briefings.
|
|
||||||
|
|
||||||
The main chat pipeline exposes 40+ tools via ``tools.get_tools_for_user``,
|
Tools are opted-in to briefings via ``@tool(briefing=True)`` in their
|
||||||
including mutating tools (``create_task``, ``delete_note``) and
|
respective module, so there is no separate allowlist to maintain here.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
from fabledassistant.services.tools import get_briefing_tools
|
||||||
|
|
||||||
from fabledassistant.services.tools import get_tools_for_user
|
__all__ = ["get_briefing_tools"]
|
||||||
|
|
||||||
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
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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)}
|
||||||
@@ -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},
|
||||||
|
}
|
||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -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())}}
|
||||||
@@ -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},
|
||||||
|
}
|
||||||
@@ -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=<int> 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}}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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}}
|
||||||
@@ -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"",
|
||||||
|
"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."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user