Switch default model to qwen3 and add intent routing for reliable tool calling

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 16:24:01 -05:00
parent d7bc3f3222
commit 75560dee4e
7 changed files with 272 additions and 31 deletions
@@ -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