From 75560dee4e0ec9e1d825c810805765cf42a64d78 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 16 Feb 2026 16:24:01 -0500 Subject: [PATCH] Switch default model to qwen3 and add intent routing for reliable tool calling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mistral didn't reliably use Ollama's structured tool calling API — it wrote tool calls as JSON text instead of invoking them. This adds an intent routing layer that classifies user intent via a fast non-streaming LLM call before streaming, executing detected tools directly and bypassing native tool calling. - Change default OLLAMA_MODEL from mistral to qwen3 - Add intent.py: classify_intent() with JSON parsing and fallback regex - Integrate intent routing into generation_task.py round 0 - Add all-day event support (iCalendar DATE values) to CalDAV service - Add recurring event support (RRULE) to CalDAV service and tool definition - Improve create_event tool description for descriptive titles - Enhance system prompt with structured tool usage guidance Co-Authored-By: Claude Opus 4.6 --- src/fabledassistant/config.py | 2 +- src/fabledassistant/services/caldav.py | 61 +++++-- .../services/generation_task.py | 32 ++++ src/fabledassistant/services/intent.py | 151 ++++++++++++++++++ src/fabledassistant/services/llm.py | 19 ++- src/fabledassistant/services/tools.py | 18 ++- summary.md | 20 ++- 7 files changed, 272 insertions(+), 31 deletions(-) create mode 100644 src/fabledassistant/services/intent.py diff --git a/src/fabledassistant/config.py b/src/fabledassistant/config.py index 5346961..3f0825d 100644 --- a/src/fabledassistant/config.py +++ b/src/fabledassistant/config.py @@ -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", "mistral") + OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "qwen3") 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") diff --git a/src/fabledassistant/services/caldav.py b/src/fabledassistant/services/caldav.py index 7d240a6..5b6c235 100644 --- a/src/fabledassistant/services/caldav.py +++ b/src/fabledassistant/services/caldav.py @@ -2,7 +2,7 @@ import asyncio import logging -from datetime import datetime, timedelta +from datetime import date as date_type, datetime, timedelta import caldav import icalendar @@ -76,30 +76,61 @@ async def create_event( duration: int | None = None, description: str | None = None, location: str | None = None, + all_day: bool = False, + recurrence: str | None = None, ) -> dict: - """Create a calendar event. start/end are ISO datetime strings.""" + """Create a calendar event. + + start/end are ISO date (YYYY-MM-DD) or datetime strings. + If all_day is True, DTSTART/DTEND use DATE values. + recurrence is an iCalendar RRULE string (e.g. "FREQ=YEARLY"). + """ 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 all_day: + # All-day events use DATE values (no time component) + d_start = datetime.fromisoformat(start).date() if "T" in start else date_type.fromisoformat(start) + if end: + d_end = datetime.fromisoformat(end).date() if "T" in end else date_type.fromisoformat(end) + else: + d_end = d_start + timedelta(days=1) + event.add("dtstart", d_start) + event.add("dtend", d_end) + result_start = d_start.isoformat() + result_end = d_end.isoformat() + else: + dt_start = datetime.fromisoformat(start) + if end: + dt_end = datetime.fromisoformat(end) + else: + dt_end = dt_start + timedelta(minutes=duration or 60) + event.add("dtstart", dt_start) + event.add("dtend", dt_end) + result_start = dt_start.isoformat() + result_end = dt_end.isoformat() + if description: event.add("description", description) if location: event.add("location", location) + if recurrence: + # Parse RRULE string like "FREQ=YEARLY" into a vRecur dict + rrule_parts = {} + for part in recurrence.split(";"): + if "=" in part: + key, value = part.split("=", 1) + rrule_parts[key.strip().lower()] = value.strip() + event.add("rrule", rrule_parts) + cal.add_component(event) ical_str = cal.to_ical().decode("utf-8") @@ -111,11 +142,15 @@ async def create_event( await asyncio.to_thread(_save) - return { + result = { "title": title, - "start": dt_start.isoformat(), - "end": dt_end.isoformat(), + "start": result_start, + "end": result_end, + "all_day": all_day, } + if recurrence: + result["recurrence"] = recurrence + return result async def list_events(user_id: int, date_from: str, date_to: str) -> list[dict]: diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py index 19acb27..fc77896 100644 --- a/src/fabledassistant/services/generation_task.py +++ b/src/fabledassistant/services/generation_task.py @@ -16,6 +16,7 @@ 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.intent import classify_intent from fabledassistant.services.tools import get_tools_for_user, execute_tool logger = logging.getLogger(__name__) @@ -102,6 +103,37 @@ async def run_generation( round_tool_calls: list[dict] = [] logger.info("Generation round %d started for conv %d (model=%s)", _round, conv_id, model) + # Intent routing — first round only + if _round == 0 and tools: + intent = await classify_intent(user_content, tools, model) + if intent.tool_name: + logger.info("Intent router detected tool: %s(%s)", intent.tool_name, json.dumps(intent.arguments)[:200]) + result = await execute_tool(user_id, intent.tool_name, intent.arguments) + logger.info("Intent-routed tool %s result: success=%s", intent.tool_name, result.get("success")) + + tool_record = { + "function": intent.tool_name, + "arguments": intent.arguments, + "result": result, + "status": "success" if result.get("success") else "error", + } + all_tool_calls.append(tool_record) + buf.append_event("tool_call", {"tool_call": tool_record}) + + # Inject into messages so LLM can write a natural response + messages.append({ + "role": "assistant", + "content": "", + "tool_calls": [ + {"function": {"name": intent.tool_name, "arguments": intent.arguments}} + ], + }) + messages.append({ + "role": "tool", + "content": json.dumps(result), + }) + continue # Next round: LLM streams response incorporating result + async for chunk in stream_chat_with_tools(messages, model, tools=tools): if buf.cancel_event.is_set(): cancelled = True diff --git a/src/fabledassistant/services/intent.py b/src/fabledassistant/services/intent.py new file mode 100644 index 0000000..c612023 --- /dev/null +++ b/src/fabledassistant/services/intent.py @@ -0,0 +1,151 @@ +"""Intent routing — classify user message before streaming. + +Makes a fast non-streaming LLM call to detect tool intent and extract +parameters. When a tool call is detected the caller can execute it +directly, bypassing the model's native (and sometimes unreliable) +structured tool-calling API. +""" + +import json +import logging +import re +from dataclasses import dataclass, field +from datetime import date as date_type + +from fabledassistant.services.llm import generate_completion + +logger = logging.getLogger(__name__) + + +@dataclass +class IntentResult: + tool_name: str | None = None # None = no tool, just chat + arguments: dict = field(default_factory=dict) + + +def _build_tool_summary(tools: list[dict]) -> str: + """Build a compact tool description string from Ollama tool defs.""" + lines: list[str] = [] + for tool in tools: + fn = tool.get("function", {}) + name = fn.get("name", "") + desc = fn.get("description", "") + params = fn.get("parameters", {}).get("properties", {}) + required = set(fn.get("parameters", {}).get("required", [])) + + param_parts: list[str] = [] + for pname, pinfo in params.items(): + req = " (required)" if pname in required else "" + pdesc = pinfo.get("description", "") + param_parts.append(f" - {pname}: {pdesc}{req}") + + lines.append(f"- {name}: {desc}") + lines.extend(param_parts) + return "\n".join(lines) + + +_SYSTEM_PROMPT_TEMPLATE = """\ +You are an intent classifier. Given a user message, decide whether it \ +requires calling one of the available tools or is just general chat. + +Today's date is {today}. + +Available tools: +{tool_summary} + +Respond with ONLY a JSON object, no other text: +- If a tool should be called: {{"tool": "tool_name", "arguments": {{...}}}} +- If it's general chat: {{"tool": null}} + +Rules: +- For dates like "tomorrow", "next Friday", "in 3 days", resolve them to YYYY-MM-DD format. +- For datetime parameters, use ISO 8601 format (e.g. 2026-09-30T14:00:00). +- Only include arguments the user actually specified or that can be clearly inferred. +- Infer reasonable defaults: birthdays and holidays are all-day + yearly recurring; "weekly meeting" is weekly recurring. +- Use descriptive titles: "My Birthday" not just "Birthday", "Team Standup" not just "Meeting". +- Do NOT wrap the JSON in markdown code fences.""" + + +async def classify_intent( + user_message: str, + tools: list[dict], + model: str, +) -> IntentResult: + """Classify user intent via a fast non-streaming LLM call. + + Returns an IntentResult. On any failure, returns IntentResult(tool_name=None) + so the caller falls through to the normal streaming path. + """ + if not tools: + return IntentResult() + + tool_summary = _build_tool_summary(tools) + today = date_type.today().isoformat() + + messages = [ + { + "role": "system", + "content": _SYSTEM_PROMPT_TEMPLATE.format( + today=today, tool_summary=tool_summary + ), + }, + {"role": "user", "content": user_message}, + ] + + try: + raw = await generate_completion(messages, model) + except Exception: + logger.warning("Intent classification LLM call failed", exc_info=True) + return IntentResult() + + return _parse_intent(raw, tools) + + +def _parse_intent(raw: str, tools: list[dict]) -> IntentResult: + """Parse the LLM's JSON response into an IntentResult.""" + text = raw.strip() + + # Strip markdown code fences if present + text = re.sub(r"^```(?:json)?\s*", "", text) + text = re.sub(r"\s*```$", "", text) + text = text.strip() + + # Try direct JSON parse + parsed = _try_json(text) + + # Fallback: extract first JSON object from response + if parsed is None: + match = re.search(r"\{.*\}", text, re.DOTALL) + if match: + parsed = _try_json(match.group()) + + if parsed is None or not isinstance(parsed, dict): + logger.warning("Could not parse intent from LLM response: %s", text[:200]) + return IntentResult() + + tool_name = parsed.get("tool") + if tool_name is None: + return IntentResult() + + # Validate tool name against available tools + valid_names = { + t.get("function", {}).get("name") for t in tools + } + if tool_name not in valid_names: + logger.warning("Intent returned unknown tool '%s'", tool_name) + return IntentResult() + + arguments = parsed.get("arguments", {}) + if not isinstance(arguments, dict): + arguments = {} + + logger.info("Intent classified: tool=%s, args=%s", tool_name, json.dumps(arguments)[:200]) + return IntentResult(tool_name=tool_name, arguments=arguments) + + +def _try_json(text: str) -> dict | list | None: + """Try to parse JSON, return None on failure.""" + try: + return json.loads(text) + except (json.JSONDecodeError, TypeError): + return None diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index d3246c9..7acd39c 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -245,18 +245,25 @@ async def build_context( 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." + + # Build tool usage guidance based on available integrations + tool_lines = [ + "You have access to tool functions. You MUST use them when the user asks you to create, add, find, schedule, or search for anything.", + "CRITICAL: Call the tool functions directly. NEVER write out function calls as text or code. NEVER describe what you would do — just do it.", + "Available actions: create_task, create_note, search_notes.", + ] if has_caldav: - date_guidance += " For calendar events, use ISO 8601 datetime format (e.g. 2025-01-15T14:00:00)." + tool_lines[-1] = "Available actions: create_task, create_note, search_notes, create_event, list_events, search_events." + tool_lines.append("For calendar events, use ISO 8601 datetime format (e.g. 2026-09-30T00:00:00).") + tool_lines.append("For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format.") + tool_guidance = "\n".join(tool_lines) 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}. " - "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}" + f"Today's date is {today}.\n\n" + f"{tool_guidance}" ] context_meta: dict = { diff --git a/src/fabledassistant/services/tools.py b/src/fabledassistant/services/tools.py index 6921e48..ec1752a 100644 --- a/src/fabledassistant/services/tools.py +++ b/src/fabledassistant/services/tools.py @@ -98,19 +98,19 @@ _CALDAV_TOOLS = [ "properties": { "title": { "type": "string", - "description": "The event title", + "description": "A descriptive event title (e.g. 'John's Birthday' not just 'Birthday')", }, "start": { "type": "string", - "description": "Start date/time in ISO 8601 format (e.g. 2025-01-15T14:00:00)", + "description": "Start date (YYYY-MM-DD for all-day) or datetime (ISO 8601, e.g. 2025-01-15T14:00:00)", }, "end": { "type": "string", - "description": "Optional end date/time in ISO 8601 format", + "description": "Optional end date or datetime in same format as start", }, "duration": { "type": "integer", - "description": "Optional duration in minutes (default 60, ignored if end is set)", + "description": "Optional duration in minutes (default 60, ignored if end is set or all_day is true)", }, "description": { "type": "string", @@ -120,6 +120,14 @@ _CALDAV_TOOLS = [ "type": "string", "description": "Optional event location", }, + "all_day": { + "type": "boolean", + "description": "Set to true for all-day events like birthdays, holidays, deadlines (default false)", + }, + "recurrence": { + "type": "string", + "description": "Optional iCalendar RRULE (e.g. 'FREQ=YEARLY' for annual, 'FREQ=WEEKLY' for weekly, 'FREQ=MONTHLY' for monthly)", + }, }, "required": ["title", "start"], }, @@ -264,6 +272,8 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict: duration=arguments.get("duration"), description=arguments.get("description"), location=arguments.get("location"), + all_day=arguments.get("all_day", False), + recurrence=arguments.get("recurrence"), ) return { "success": True, diff --git a/summary.md b/summary.md index 88c4f32..59fb974 100644 --- a/summary.md +++ b/summary.md @@ -12,7 +12,7 @@ > Include file-level details in the commit body when the change is non-trivial. ## Last Updated -2026-02-15 — Phase 8: CalDAV calendar integration, LLM-suggested tags, settings/model UI refinements +2026-02-16 — Phase 9: Switch to qwen3, intent routing for reliable tool calling, all-day/recurring events ## Project Overview Fabled Assistant is a self-hosted note-taking and task-tracking application with @@ -263,10 +263,11 @@ fabledassistant/ │ │ ├── llm.py # Ollama interaction: build_context with user_id, streaming (stream_chat + stream_chat_with_tools), ChatChunk dataclass, URL fetching │ │ ├── chat.py # Conversation CRUD with user_id isolation, add_message, save/summarize as note (LLM-titled, chat-tagged) │ │ ├── generation_buffer.py # In-memory SSE event buffer with cancel_event, reconnect support, auto-cleanup; supports chat (int keys) and assist (string keys) -│ │ ├── generation_task.py # Background asyncio tasks: run_generation (chat, DB flush, titles, tool loop) + run_assist_generation (lightweight, no DB) -│ │ ├── tools.py # LLM tool definitions (create_task, create_note, search_notes, CalDAV events) + execute_tool dispatcher +│ │ ├── generation_task.py # Background asyncio tasks: run_generation (chat, DB flush, titles, intent routing + tool loop) + run_assist_generation (lightweight, no DB) +│ │ ├── intent.py # Intent routing: classify_intent() makes fast non-streaming LLM call to detect tool intent before streaming +│ │ ├── tools.py # LLM tool definitions (create_task, create_note, search_notes, CalDAV events with all-day/recurrence) + execute_tool dispatcher │ │ ├── tag_suggestions.py # LLM-powered tag suggestions: suggest_tags() builds prompt with existing tags, calls generate_completion, parses JSON response -│ │ ├── caldav.py # CalDAV integration: create/list/search calendar events via caldav library (per-user config from settings) +│ │ ├── caldav.py # CalDAV integration: create/list/search calendar events via caldav library, all-day + recurring event support (per-user config from settings) │ │ ├── settings.py # Settings CRUD with user_id isolation: get_setting, set_setting, set_settings_batch, get_all_settings │ │ ├── logging.py # App logging: log_audit, log_usage, log_error, get_logs, get_log_stats, delete_old_logs, start_log_retention_loop │ │ ├── email.py # SMTP email service: get_smtp_config, is_smtp_configured, send_email, send_test_email @@ -523,7 +524,7 @@ When adding a new migration, follow these conventions: - All task sections hidden when empty; marking done removes from all lists ### LLM Chat -- Ollama integration via async HTTP (httpx), auto-pull default model on startup +- Ollama integration via async HTTP (httpx), default model qwen3 (better tool support than mistral), auto-pull on startup - Background generation with `GenerationBuffer` (in-memory SSE fan-out, `Last-Event-ID` reconnect, 60s cleanup) - Stop generation with partial content preservation - Note-aware context building: current note + keyword search for related notes + URL fetching @@ -546,9 +547,14 @@ When adding a new migration, follow these conventions: components (linked titles for created items, search result lists). SSE emits `tool_call` events for real-time rendering during streaming. System prompt includes today's date for relative date resolution. Graceful degradation: models without tool support respond normally. +- **Intent routing:** Before streaming, a fast non-streaming LLM call classifies user intent and + extracts tool parameters (`services/intent.py`). If a tool call is detected, it executes directly + — bypassing the model's native (sometimes unreliable) tool calling API. Falls through to normal + streaming when no tool is detected or classification fails. Only runs on first round of tool loop. - **CalDAV calendar integration:** Per-user CalDAV settings (URL, username, password, calendar name). - LLM tools: `create_event`, `list_events`, `search_events`. Runs synchronous caldav library calls - in asyncio executor. Settings UI for CalDAV configuration. + LLM tools: `create_event` (with `all_day` and `recurrence` support), `list_events`, `search_events`. + All-day events use iCalendar DATE values; recurrence uses RRULE (e.g. `FREQ=YEARLY`). + Runs synchronous caldav library calls in asyncio executor. Settings UI for CalDAV configuration. - **LLM-suggested tags:** Backend service (`tag_suggestions.py`) prompts LLM with existing user tags and note content, returns 3-5 relevant tag suggestions. Tags already in body are filtered out. Exposed via `POST /api/notes/suggest-tags` and `POST /api/notes/:id/append-tag`. Integrated in: