Add CalDAV calendar integration, LLM-suggested tags, and settings refinements
- CalDAV integration: per-user calendar config, create/list/search events via caldav library, LLM tools for calendar operations from chat - LLM-suggested tags: new tag_suggestions service prompts LLM with existing tags and note content to suggest 3-5 relevant tags; exposed via API endpoints (suggest-tags, append-tag); integrated into editor views (suggest button + clickable pills) and chat tool calls (pills in ToolCallCard with one-click apply) - Settings/model UI refinements, generation task improvements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,7 @@ class Config:
|
||||
"postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant",
|
||||
)
|
||||
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
||||
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "llama3.1")
|
||||
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "mistral")
|
||||
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
|
||||
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
|
||||
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
|
||||
|
||||
@@ -27,6 +27,7 @@ from fabledassistant.services.notes import (
|
||||
update_note,
|
||||
)
|
||||
from fabledassistant.services.settings import get_setting
|
||||
from fabledassistant.services.tag_suggestions import suggest_tags
|
||||
from fabledassistant.utils.tags import extract_tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -95,6 +96,37 @@ async def list_tags_route():
|
||||
return jsonify({"tags": tags})
|
||||
|
||||
|
||||
@notes_bp.route("/suggest-tags", methods=["POST"])
|
||||
@login_required
|
||||
async def suggest_tags_route():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
title = data.get("title", "")
|
||||
body = data.get("body", "")
|
||||
tags = await suggest_tags(uid, title, body)
|
||||
return jsonify({"suggested_tags": tags})
|
||||
|
||||
|
||||
@notes_bp.route("/<int:note_id>/append-tag", methods=["POST"])
|
||||
@login_required
|
||||
async def append_tag_route(note_id: int):
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
tag = data.get("tag", "").strip()
|
||||
if not tag:
|
||||
return jsonify({"error": "tag is required"}), 400
|
||||
|
||||
note = await get_note(uid, note_id)
|
||||
if note is None:
|
||||
return jsonify({"error": "Note not found"}), 404
|
||||
|
||||
# Append #tag to body
|
||||
new_body = note.body.rstrip() + f"\n#{tag}" if note.body else f"#{tag}"
|
||||
tags = extract_tags(new_body)
|
||||
updated = await update_note(uid, note_id, body=new_body, tags=tags)
|
||||
return jsonify(updated.to_dict())
|
||||
|
||||
|
||||
@notes_bp.route("/by-title", methods=["GET"])
|
||||
@login_required
|
||||
async def get_note_by_title_route():
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from fabledassistant.auth import login_required, get_current_user_id
|
||||
from fabledassistant.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection
|
||||
from fabledassistant.services.llm import get_installed_models
|
||||
from fabledassistant.services.settings import get_all_settings, set_settings_batch
|
||||
|
||||
@@ -32,3 +33,39 @@ async def update_settings_route():
|
||||
await set_settings_batch(uid, {k: str(v) for k, v in data.items()})
|
||||
settings = await get_all_settings(uid)
|
||||
return jsonify(settings)
|
||||
|
||||
|
||||
@settings_bp.route("/caldav", methods=["GET"])
|
||||
@login_required
|
||||
async def get_caldav():
|
||||
uid = get_current_user_id()
|
||||
config = await get_caldav_config(uid)
|
||||
if config.get("caldav_password"):
|
||||
config["caldav_password"] = "********"
|
||||
return jsonify(config)
|
||||
|
||||
|
||||
@settings_bp.route("/caldav", methods=["PUT"])
|
||||
@login_required
|
||||
async def update_caldav():
|
||||
uid = get_current_user_id()
|
||||
data = await request.get_json()
|
||||
|
||||
settings_to_save = {}
|
||||
for key in CALDAV_SETTING_KEYS:
|
||||
if key in data:
|
||||
if key == "caldav_password" and data[key] == "********":
|
||||
continue
|
||||
settings_to_save[key] = str(data[key])
|
||||
|
||||
if settings_to_save:
|
||||
await set_settings_batch(uid, settings_to_save)
|
||||
return jsonify({"status": "ok"})
|
||||
|
||||
|
||||
@settings_bp.route("/caldav/test", methods=["POST"])
|
||||
@login_required
|
||||
async def test_caldav():
|
||||
uid = get_current_user_id()
|
||||
result = await test_connection(uid)
|
||||
return jsonify(result)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""CalDAV calendar integration service."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import caldav
|
||||
import icalendar
|
||||
|
||||
from fabledassistant.services.settings import get_all_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name"]
|
||||
|
||||
|
||||
async def get_caldav_config(user_id: int) -> dict[str, str]:
|
||||
"""Read CalDAV settings for the given user."""
|
||||
all_settings = await get_all_settings(user_id)
|
||||
return {k: all_settings.get(k, "") for k in CALDAV_SETTING_KEYS}
|
||||
|
||||
|
||||
async def is_caldav_configured(user_id: int) -> bool:
|
||||
"""Check if CalDAV URL, username, and password are all set."""
|
||||
config = await get_caldav_config(user_id)
|
||||
return bool(config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password"))
|
||||
|
||||
|
||||
def _get_calendar(client: caldav.DAVClient, calendar_name: str) -> caldav.Calendar:
|
||||
"""Get a named calendar or the first available one (synchronous)."""
|
||||
principal = client.principal()
|
||||
calendars = principal.calendars()
|
||||
if not calendars:
|
||||
raise ValueError("No calendars found on the CalDAV server.")
|
||||
if calendar_name:
|
||||
for cal in calendars:
|
||||
if cal.name == calendar_name:
|
||||
return cal
|
||||
names = [c.name for c in calendars]
|
||||
raise ValueError(f"Calendar '{calendar_name}' not found. Available: {', '.join(names)}")
|
||||
return calendars[0]
|
||||
|
||||
|
||||
def _make_client(config: dict[str, str]) -> caldav.DAVClient:
|
||||
"""Create a CalDAV client from config dict."""
|
||||
return caldav.DAVClient(
|
||||
url=config["caldav_url"],
|
||||
username=config["caldav_username"],
|
||||
password=config["caldav_password"],
|
||||
)
|
||||
|
||||
|
||||
def _parse_vevent(component) -> dict | None:
|
||||
"""Extract event data from a VEVENT component."""
|
||||
if component.name != "VEVENT":
|
||||
return None
|
||||
title = str(component.get("SUMMARY", ""))
|
||||
dtstart = component.get("DTSTART")
|
||||
dtend = component.get("DTEND")
|
||||
location = str(component.get("LOCATION", ""))
|
||||
start_str = dtstart.dt.isoformat() if dtstart else ""
|
||||
end_str = dtend.dt.isoformat() if dtend else ""
|
||||
return {
|
||||
"title": title,
|
||||
"start": start_str,
|
||||
"end": end_str,
|
||||
"location": location,
|
||||
}
|
||||
|
||||
|
||||
async def create_event(
|
||||
user_id: int,
|
||||
title: str,
|
||||
start: str,
|
||||
end: str | None = None,
|
||||
duration: int | None = None,
|
||||
description: str | None = None,
|
||||
location: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a calendar event. start/end are ISO datetime strings."""
|
||||
config = await get_caldav_config(user_id)
|
||||
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
|
||||
raise ValueError("CalDAV is not configured. Go to Settings → Calendar to set it up.")
|
||||
|
||||
dt_start = datetime.fromisoformat(start)
|
||||
if end:
|
||||
dt_end = datetime.fromisoformat(end)
|
||||
else:
|
||||
dt_end = dt_start + timedelta(minutes=duration or 60)
|
||||
|
||||
cal = icalendar.Calendar()
|
||||
cal.add("prodid", "-//FabledAssistant//EN")
|
||||
cal.add("version", "2.0")
|
||||
|
||||
event = icalendar.Event()
|
||||
event.add("summary", title)
|
||||
event.add("dtstart", dt_start)
|
||||
event.add("dtend", dt_end)
|
||||
if description:
|
||||
event.add("description", description)
|
||||
if location:
|
||||
event.add("location", location)
|
||||
cal.add_component(event)
|
||||
|
||||
ical_str = cal.to_ical().decode("utf-8")
|
||||
|
||||
def _save():
|
||||
client = _make_client(config)
|
||||
calendar = _get_calendar(client, config.get("caldav_calendar_name", ""))
|
||||
calendar.save_event(ical_str)
|
||||
|
||||
await asyncio.to_thread(_save)
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"start": dt_start.isoformat(),
|
||||
"end": dt_end.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
async def list_events(user_id: int, date_from: str, date_to: str) -> list[dict]:
|
||||
"""List calendar events in a date range. Dates are ISO datetime strings."""
|
||||
config = await get_caldav_config(user_id)
|
||||
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
|
||||
raise ValueError("CalDAV is not configured. Go to Settings → Calendar to set it up.")
|
||||
|
||||
dt_from = datetime.fromisoformat(date_from)
|
||||
dt_to = datetime.fromisoformat(date_to)
|
||||
|
||||
def _search():
|
||||
client = _make_client(config)
|
||||
calendar = _get_calendar(client, config.get("caldav_calendar_name", ""))
|
||||
return calendar.date_search(dt_from, dt_to)
|
||||
|
||||
results = await asyncio.to_thread(_search)
|
||||
|
||||
events = []
|
||||
for result in results:
|
||||
cal = icalendar.Calendar.from_ical(result.data)
|
||||
for component in cal.walk():
|
||||
parsed = _parse_vevent(component)
|
||||
if parsed:
|
||||
events.append(parsed)
|
||||
return events
|
||||
|
||||
|
||||
async def search_events(user_id: int, query: str, days_ahead: int = 90) -> list[dict]:
|
||||
"""Search events by keyword in the next N days."""
|
||||
now = datetime.now()
|
||||
date_from = now.isoformat()
|
||||
date_to = (now + timedelta(days=days_ahead)).isoformat()
|
||||
|
||||
all_events = await list_events(user_id, date_from, date_to)
|
||||
q = query.lower()
|
||||
return [
|
||||
e for e in all_events
|
||||
if q in e["title"].lower() or q in e.get("location", "").lower()
|
||||
]
|
||||
|
||||
|
||||
async def test_connection(user_id: int) -> dict:
|
||||
"""Test the CalDAV connection and return status."""
|
||||
config = await get_caldav_config(user_id)
|
||||
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
|
||||
return {"success": False, "error": "CalDAV is not configured. Please fill in URL, username, and password."}
|
||||
|
||||
def _test():
|
||||
client = _make_client(config)
|
||||
principal = client.principal()
|
||||
calendars = principal.calendars()
|
||||
return [c.name for c in calendars]
|
||||
|
||||
try:
|
||||
calendar_names = await asyncio.to_thread(_test)
|
||||
return {
|
||||
"success": True,
|
||||
"calendars": calendar_names,
|
||||
"message": f"Connected successfully. Found {len(calendar_names)} calendar(s).",
|
||||
}
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "401" in error_msg or "403" in error_msg or "Unauthorized" in error_msg:
|
||||
error_msg = "Authentication failed. Check your username and password."
|
||||
elif "404" in error_msg or "Not Found" in error_msg:
|
||||
error_msg = "CalDAV endpoint not found. Check your URL."
|
||||
elif "Connection" in error_msg or "resolve" in error_msg:
|
||||
error_msg = f"Connection failed: {error_msg}"
|
||||
return {"success": False, "error": error_msg}
|
||||
@@ -6,6 +6,7 @@ Runs independently of any HTTP connection.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from sqlalchemy import update
|
||||
@@ -15,10 +16,13 @@ from fabledassistant.models.conversation import Message
|
||||
from fabledassistant.services.generation_buffer import GenerationBuffer, GenerationState
|
||||
from fabledassistant.services.llm import ChatChunk, generate_completion, stream_chat, stream_chat_with_tools
|
||||
from fabledassistant.services.chat import update_conversation_title
|
||||
from fabledassistant.services.tools import TOOL_DEFINITIONS, execute_tool
|
||||
from fabledassistant.services.tools import get_tools_for_user, execute_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Mistral prefixes tool-call responses with "[TOOL_CALLS]" as visible text
|
||||
_TOOL_CALL_MARKER = re.compile(r"^\s*\[TOOL_CALLS\]\s*", re.IGNORECASE)
|
||||
|
||||
DB_FLUSH_INTERVAL = 5.0 # seconds between partial DB flushes
|
||||
|
||||
|
||||
@@ -87,20 +91,28 @@ async def run_generation(
|
||||
last_flush = time.monotonic()
|
||||
all_tool_calls: list[dict] = []
|
||||
|
||||
# Resolve tools based on user's configured integrations
|
||||
tools = await get_tools_for_user(user_id)
|
||||
logger.info("Starting generation for conv %d: model=%s, tools=%d", conv_id, model, len(tools))
|
||||
|
||||
try:
|
||||
cancelled = False
|
||||
|
||||
for _round in range(MAX_TOOL_ROUNDS + 1):
|
||||
round_tool_calls: list[dict] = []
|
||||
logger.info("Generation round %d started for conv %d (model=%s)", _round, conv_id, model)
|
||||
|
||||
async for chunk in stream_chat_with_tools(messages, model, tools=TOOL_DEFINITIONS):
|
||||
async for chunk in stream_chat_with_tools(messages, model, tools=tools):
|
||||
if buf.cancel_event.is_set():
|
||||
cancelled = True
|
||||
break
|
||||
|
||||
if chunk.type == "content":
|
||||
buf.content_so_far += chunk.content
|
||||
buf.append_event("chunk", {"chunk": chunk.content})
|
||||
# Filter out "[TOOL_CALLS]" marker from streaming output
|
||||
clean = _TOOL_CALL_MARKER.sub("", chunk.content)
|
||||
if clean:
|
||||
buf.append_event("chunk", {"chunk": clean})
|
||||
|
||||
# Periodic DB flush
|
||||
now = time.monotonic()
|
||||
@@ -112,13 +124,16 @@ async def run_generation(
|
||||
last_flush = now
|
||||
|
||||
elif chunk.type == "tool_calls" and chunk.tool_calls:
|
||||
logger.info("Round %d: model returned %d tool call(s)", _round, len(chunk.tool_calls))
|
||||
# Process each tool call
|
||||
for tc in chunk.tool_calls:
|
||||
fn = tc.get("function", {})
|
||||
tool_name = fn.get("name", "")
|
||||
arguments = fn.get("arguments", {})
|
||||
logger.info("Executing tool: %s(%s)", tool_name, json.dumps(arguments)[:200])
|
||||
|
||||
result = await execute_tool(user_id, tool_name, arguments)
|
||||
logger.info("Tool %s result: success=%s", tool_name, result.get("success"))
|
||||
|
||||
tool_record = {
|
||||
"function": tool_name,
|
||||
@@ -133,12 +148,19 @@ async def run_generation(
|
||||
buf.append_event("tool_call", {"tool_call": tool_record})
|
||||
|
||||
if cancelled:
|
||||
logger.info("Generation cancelled for conv %d", conv_id)
|
||||
break
|
||||
|
||||
# If no tool calls this round, the LLM gave its final text response
|
||||
if not round_tool_calls:
|
||||
logger.info("Round %d: no tool calls, final content length=%d", _round, len(buf.content_so_far))
|
||||
break
|
||||
|
||||
logger.info("Round %d: %d tool call(s) executed, starting next round", _round, len(round_tool_calls))
|
||||
|
||||
# Strip model artifacts like "[TOOL_CALLS]" from content
|
||||
buf.content_so_far = _TOOL_CALL_MARKER.sub("", buf.content_so_far)
|
||||
|
||||
# Append assistant tool_call message and tool results to conversation
|
||||
# for the next round
|
||||
messages.append({
|
||||
@@ -159,7 +181,12 @@ async def run_generation(
|
||||
# Reset content for the next round (LLM will produce a new response)
|
||||
buf.content_so_far = ""
|
||||
|
||||
# Strip model artifacts from final content
|
||||
buf.content_so_far = _TOOL_CALL_MARKER.sub("", buf.content_so_far)
|
||||
|
||||
# Final save
|
||||
logger.info("Generation complete for conv %d: content_length=%d, tool_calls=%d",
|
||||
conv_id, len(buf.content_so_far), len(all_tool_calls))
|
||||
await _update_message(
|
||||
msg_id,
|
||||
buf.content_so_far,
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Literal
|
||||
import httpx
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.caldav import is_caldav_configured
|
||||
from fabledassistant.services.notes import get_note, search_notes_for_context
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
@@ -79,9 +80,10 @@ async def stream_chat(
|
||||
options: dict | None = None,
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Stream chat completion from Ollama, yielding content chunks."""
|
||||
payload: dict = {"model": model, "messages": messages, "stream": True}
|
||||
merged_options = {"num_ctx": 16384}
|
||||
if options:
|
||||
payload["options"] = options
|
||||
merged_options.update(options)
|
||||
payload: dict = {"model": model, "messages": messages, "stream": True, "options": merged_options}
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(1800.0, connect=30.0, read=300.0)) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
@@ -119,7 +121,17 @@ async def stream_chat_with_tools(
|
||||
ChatChunk(type="tool_calls") is yielded. Always ends with
|
||||
ChatChunk(type="done").
|
||||
"""
|
||||
payload: dict = {"model": model, "messages": messages, "stream": True}
|
||||
options: dict = {"num_ctx": 16384}
|
||||
# Disable thinking mode for models like qwen3 — it interferes with tool calling
|
||||
if tools:
|
||||
options["num_predict"] = 4096
|
||||
payload: dict = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"stream": True,
|
||||
"options": options,
|
||||
"think": False,
|
||||
}
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(1800.0, connect=30.0, read=300.0)) as client:
|
||||
@@ -129,6 +141,7 @@ async def stream_chat_with_tools(
|
||||
json=payload,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
accumulated_tool_calls: list[dict] = []
|
||||
async for line in resp.aiter_lines():
|
||||
if not line.strip():
|
||||
continue
|
||||
@@ -140,11 +153,22 @@ async def stream_chat_with_tools(
|
||||
if chunk:
|
||||
yield ChatChunk(type="content", content=chunk)
|
||||
|
||||
# Check for tool calls on done
|
||||
# Collect tool calls from any message (some models
|
||||
# emit them before the done flag)
|
||||
tc = msg.get("tool_calls")
|
||||
if tc:
|
||||
accumulated_tool_calls.extend(tc)
|
||||
|
||||
if data.get("done"):
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if tool_calls:
|
||||
yield ChatChunk(type="tool_calls", tool_calls=tool_calls)
|
||||
if accumulated_tool_calls:
|
||||
logger.info(
|
||||
"Ollama returned %d tool call(s): %s",
|
||||
len(accumulated_tool_calls),
|
||||
json.dumps(accumulated_tool_calls)[:500],
|
||||
)
|
||||
yield ChatChunk(type="tool_calls", tool_calls=accumulated_tool_calls)
|
||||
else:
|
||||
logger.debug("Ollama done with no tool calls")
|
||||
yield ChatChunk(type="done")
|
||||
break
|
||||
|
||||
@@ -220,14 +244,19 @@ async def build_context(
|
||||
from datetime import date as date_type
|
||||
assistant_name = await get_setting(user_id, "assistant_name", "Fable")
|
||||
today = date_type.today().isoformat()
|
||||
has_caldav = await is_caldav_configured(user_id)
|
||||
date_guidance = "For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format."
|
||||
if has_caldav:
|
||||
date_guidance += " For calendar events, use ISO 8601 datetime format (e.g. 2025-01-15T14:00:00)."
|
||||
|
||||
system_parts = [
|
||||
f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. "
|
||||
"Help users with their notes, tasks, and general questions. "
|
||||
"When note context is provided, use it to give relevant answers. "
|
||||
f"Today's date is {today}. "
|
||||
"You have tools available to create tasks, create notes, and search the user's notes. "
|
||||
"Use them when the user asks you to create, add, or find tasks and notes. "
|
||||
"For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format."
|
||||
"When the user asks you to create, add, or find something, use the provided tool functions. "
|
||||
"Do not describe or write out function calls as text — actually invoke the tools. "
|
||||
f"{date_guidance}"
|
||||
]
|
||||
|
||||
context_meta: dict = {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""LLM-powered tag suggestions for notes and tasks."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
from fabledassistant.services.notes import get_all_tags
|
||||
from fabledassistant.services.settings import get_setting
|
||||
from fabledassistant.utils.tags import extract_tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def suggest_tags(user_id: int, title: str, body: str) -> list[str]:
|
||||
"""Suggest relevant tags for a note/task based on its content.
|
||||
|
||||
Returns a list of tag strings (without # prefix), excluding tags
|
||||
already present in the body.
|
||||
"""
|
||||
if not title.strip() and not body.strip():
|
||||
return []
|
||||
|
||||
existing_tags = await get_all_tags(user_id)
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
existing_list = ", ".join(f"#{t}" for t in existing_tags) if existing_tags else "(none yet)"
|
||||
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are a tag suggestion assistant. Given a note's title and body, "
|
||||
"suggest 3-5 relevant tags. Prefer reusing existing tags when they fit, "
|
||||
"but you may suggest new ones too. Reply with ONLY a JSON array of tag "
|
||||
'strings (without # prefix). Example: ["meeting", "project/alpha", "followup"]\n\n'
|
||||
f"Existing tags: {existing_list}"
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": f"Title: {title}\n\nBody:\n{body[:2000]}",
|
||||
},
|
||||
]
|
||||
|
||||
try:
|
||||
response = await generate_completion(messages, model)
|
||||
except Exception:
|
||||
logger.warning("Tag suggestion LLM call failed", exc_info=True)
|
||||
return []
|
||||
|
||||
tags = _parse_tag_list(response)
|
||||
|
||||
# Filter out tags already in the body
|
||||
body_tags = set(extract_tags(body))
|
||||
tags = [t for t in tags if t not in body_tags]
|
||||
|
||||
return tags[:5]
|
||||
|
||||
|
||||
def _parse_tag_list(response: str) -> list[str]:
|
||||
"""Parse a JSON array of tags from LLM response, with fallback for markdown wrapping."""
|
||||
text = response.strip()
|
||||
|
||||
# Try direct JSON parse
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
if isinstance(parsed, list):
|
||||
return [str(t).strip().lstrip("#") for t in parsed if t]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Fallback: extract JSON array from markdown code block
|
||||
match = re.search(r"\[.*?\]", text, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
parsed = json.loads(match.group())
|
||||
if isinstance(parsed, list):
|
||||
return [str(t).strip().lstrip("#") for t in parsed if t]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
logger.warning("Could not parse tag suggestions from LLM response: %s", text[:200])
|
||||
return []
|
||||
@@ -3,11 +3,19 @@
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
|
||||
from fabledassistant.services.caldav import (
|
||||
create_event,
|
||||
is_caldav_configured,
|
||||
list_events,
|
||||
search_events,
|
||||
)
|
||||
from fabledassistant.services.notes import create_note, list_notes
|
||||
from fabledassistant.services.tag_suggestions import suggest_tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TOOL_DEFINITIONS = [
|
||||
# Core tools — always available
|
||||
_CORE_TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -78,6 +86,94 @@ TOOL_DEFINITIONS = [
|
||||
},
|
||||
]
|
||||
|
||||
# CalDAV tools — only included when user has CalDAV configured
|
||||
_CALDAV_TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_event",
|
||||
"description": "Create a calendar event for the user. Use this when the user asks to schedule, add, or create a meeting, appointment, or event.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The event title",
|
||||
},
|
||||
"start": {
|
||||
"type": "string",
|
||||
"description": "Start date/time in ISO 8601 format (e.g. 2025-01-15T14:00:00)",
|
||||
},
|
||||
"end": {
|
||||
"type": "string",
|
||||
"description": "Optional end date/time in ISO 8601 format",
|
||||
},
|
||||
"duration": {
|
||||
"type": "integer",
|
||||
"description": "Optional duration in minutes (default 60, ignored if end is set)",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Optional event description",
|
||||
},
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "Optional event location",
|
||||
},
|
||||
},
|
||||
"required": ["title", "start"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_events",
|
||||
"description": "List calendar events in a date range. Use this when the user asks what events or meetings they have.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"date_from": {
|
||||
"type": "string",
|
||||
"description": "Start of range in ISO 8601 format (e.g. 2025-01-15T00:00:00)",
|
||||
},
|
||||
"date_to": {
|
||||
"type": "string",
|
||||
"description": "End of range in ISO 8601 format (e.g. 2025-01-22T23:59:59)",
|
||||
},
|
||||
},
|
||||
"required": ["date_from", "date_to"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_events",
|
||||
"description": "Search calendar events by keyword. Use this when the user asks to find a specific event or meeting.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search keyword to match against event titles and locations",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def get_tools_for_user(user_id: int) -> list[dict]:
|
||||
"""Build the tool list for a user based on their configured integrations."""
|
||||
tools = list(_CORE_TOOLS)
|
||||
if await is_caldav_configured(user_id):
|
||||
tools.extend(_CALDAV_TOOLS)
|
||||
logger.debug("User %d: %d tools available", user_id, len(tools))
|
||||
return tools
|
||||
|
||||
|
||||
def _parse_due_date(value: str | None) -> date | None:
|
||||
"""Parse a due date string, returning None on failure."""
|
||||
@@ -94,14 +190,17 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
"""Execute a tool call and return the result."""
|
||||
try:
|
||||
if tool_name == "create_task":
|
||||
task_title = arguments.get("title", "Untitled Task")
|
||||
task_body = arguments.get("body", "")
|
||||
note = await create_note(
|
||||
user_id=user_id,
|
||||
title=arguments.get("title", "Untitled Task"),
|
||||
body=arguments.get("body", ""),
|
||||
title=task_title,
|
||||
body=task_body,
|
||||
status="todo",
|
||||
priority=arguments.get("priority", "none"),
|
||||
due_date=_parse_due_date(arguments.get("due_date")),
|
||||
)
|
||||
suggested = await suggest_tags(user_id, task_title, task_body)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "task",
|
||||
@@ -112,14 +211,18 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
"priority": note.priority,
|
||||
"due_date": str(note.due_date) if note.due_date else None,
|
||||
},
|
||||
"suggested_tags": suggested,
|
||||
}
|
||||
|
||||
elif tool_name == "create_note":
|
||||
note_title = arguments.get("title", "Untitled Note")
|
||||
note_body = arguments.get("body", "")
|
||||
note = await create_note(
|
||||
user_id=user_id,
|
||||
title=arguments.get("title", "Untitled Note"),
|
||||
body=arguments.get("body", ""),
|
||||
title=note_title,
|
||||
body=note_body,
|
||||
)
|
||||
suggested = await suggest_tags(user_id, note_title, note_body)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "note",
|
||||
@@ -127,6 +230,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
"id": note.id,
|
||||
"title": note.title,
|
||||
},
|
||||
"suggested_tags": suggested,
|
||||
}
|
||||
|
||||
elif tool_name == "search_notes":
|
||||
@@ -151,6 +255,52 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
||||
},
|
||||
}
|
||||
|
||||
elif tool_name == "create_event":
|
||||
result = await create_event(
|
||||
user_id=user_id,
|
||||
title=arguments.get("title", "Untitled Event"),
|
||||
start=arguments["start"],
|
||||
end=arguments.get("end"),
|
||||
duration=arguments.get("duration"),
|
||||
description=arguments.get("description"),
|
||||
location=arguments.get("location"),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "event",
|
||||
"data": result,
|
||||
}
|
||||
|
||||
elif tool_name == "list_events":
|
||||
events = await list_events(
|
||||
user_id=user_id,
|
||||
date_from=arguments["date_from"],
|
||||
date_to=arguments["date_to"],
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "events",
|
||||
"data": {
|
||||
"count": len(events),
|
||||
"events": events,
|
||||
},
|
||||
}
|
||||
|
||||
elif tool_name == "search_events":
|
||||
events = await search_events(
|
||||
user_id=user_id,
|
||||
query=arguments.get("query", ""),
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"type": "events",
|
||||
"data": {
|
||||
"query": arguments.get("query", ""),
|
||||
"count": len(events),
|
||||
"events": events,
|
||||
},
|
||||
}
|
||||
|
||||
else:
|
||||
return {"success": False, "error": f"Unknown tool: {tool_name}"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user