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:
@@ -23,7 +23,7 @@ class Config:
|
|||||||
"postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant",
|
"postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant",
|
||||||
)
|
)
|
||||||
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
|
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")
|
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")
|
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
|
||||||
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
|
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import date as date_type, datetime, timedelta
|
||||||
|
|
||||||
import caldav
|
import caldav
|
||||||
import icalendar
|
import icalendar
|
||||||
@@ -76,30 +76,61 @@ async def create_event(
|
|||||||
duration: int | None = None,
|
duration: int | None = None,
|
||||||
description: str | None = None,
|
description: str | None = None,
|
||||||
location: str | None = None,
|
location: str | None = None,
|
||||||
|
all_day: bool = False,
|
||||||
|
recurrence: str | None = None,
|
||||||
) -> dict:
|
) -> 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)
|
config = await get_caldav_config(user_id)
|
||||||
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
|
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.")
|
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 = icalendar.Calendar()
|
||||||
cal.add("prodid", "-//FabledAssistant//EN")
|
cal.add("prodid", "-//FabledAssistant//EN")
|
||||||
cal.add("version", "2.0")
|
cal.add("version", "2.0")
|
||||||
|
|
||||||
event = icalendar.Event()
|
event = icalendar.Event()
|
||||||
event.add("summary", title)
|
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:
|
if description:
|
||||||
event.add("description", description)
|
event.add("description", description)
|
||||||
if location:
|
if location:
|
||||||
event.add("location", 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)
|
cal.add_component(event)
|
||||||
|
|
||||||
ical_str = cal.to_ical().decode("utf-8")
|
ical_str = cal.to_ical().decode("utf-8")
|
||||||
@@ -111,11 +142,15 @@ async def create_event(
|
|||||||
|
|
||||||
await asyncio.to_thread(_save)
|
await asyncio.to_thread(_save)
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
"title": title,
|
"title": title,
|
||||||
"start": dt_start.isoformat(),
|
"start": result_start,
|
||||||
"end": dt_end.isoformat(),
|
"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]:
|
async def list_events(user_id: int, date_from: str, date_to: str) -> list[dict]:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from fabledassistant.models.conversation import Message
|
|||||||
from fabledassistant.services.generation_buffer import GenerationBuffer, GenerationState
|
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.llm import ChatChunk, generate_completion, stream_chat, stream_chat_with_tools
|
||||||
from fabledassistant.services.chat import update_conversation_title
|
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
|
from fabledassistant.services.tools import get_tools_for_user, execute_tool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -102,6 +103,37 @@ async def run_generation(
|
|||||||
round_tool_calls: list[dict] = []
|
round_tool_calls: list[dict] = []
|
||||||
logger.info("Generation round %d started for conv %d (model=%s)", _round, conv_id, model)
|
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):
|
async for chunk in stream_chat_with_tools(messages, model, tools=tools):
|
||||||
if buf.cancel_event.is_set():
|
if buf.cancel_event.is_set():
|
||||||
cancelled = True
|
cancelled = True
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -245,18 +245,25 @@ async def build_context(
|
|||||||
assistant_name = await get_setting(user_id, "assistant_name", "Fable")
|
assistant_name = await get_setting(user_id, "assistant_name", "Fable")
|
||||||
today = date_type.today().isoformat()
|
today = date_type.today().isoformat()
|
||||||
has_caldav = await is_caldav_configured(user_id)
|
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:
|
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 = [
|
system_parts = [
|
||||||
f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. "
|
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. "
|
"Help users with their notes, tasks, and general questions. "
|
||||||
"When note context is provided, use it to give relevant answers. "
|
"When note context is provided, use it to give relevant answers. "
|
||||||
f"Today's date is {today}. "
|
f"Today's date is {today}.\n\n"
|
||||||
"When the user asks you to create, add, or find something, use the provided tool functions. "
|
f"{tool_guidance}"
|
||||||
"Do not describe or write out function calls as text — actually invoke the tools. "
|
|
||||||
f"{date_guidance}"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
context_meta: dict = {
|
context_meta: dict = {
|
||||||
|
|||||||
@@ -98,19 +98,19 @@ _CALDAV_TOOLS = [
|
|||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The event title",
|
"description": "A descriptive event title (e.g. 'John's Birthday' not just 'Birthday')",
|
||||||
},
|
},
|
||||||
"start": {
|
"start": {
|
||||||
"type": "string",
|
"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": {
|
"end": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional end date/time in ISO 8601 format",
|
"description": "Optional end date or datetime in same format as start",
|
||||||
},
|
},
|
||||||
"duration": {
|
"duration": {
|
||||||
"type": "integer",
|
"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": {
|
"description": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -120,6 +120,14 @@ _CALDAV_TOOLS = [
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional event location",
|
"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"],
|
"required": ["title", "start"],
|
||||||
},
|
},
|
||||||
@@ -264,6 +272,8 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
|
|||||||
duration=arguments.get("duration"),
|
duration=arguments.get("duration"),
|
||||||
description=arguments.get("description"),
|
description=arguments.get("description"),
|
||||||
location=arguments.get("location"),
|
location=arguments.get("location"),
|
||||||
|
all_day=arguments.get("all_day", False),
|
||||||
|
recurrence=arguments.get("recurrence"),
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|||||||
+13
-7
@@ -12,7 +12,7 @@
|
|||||||
> Include file-level details in the commit body when the change is non-trivial.
|
> Include file-level details in the commit body when the change is non-trivial.
|
||||||
|
|
||||||
## Last Updated
|
## 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
|
## Project Overview
|
||||||
Fabled Assistant is a self-hosted note-taking and task-tracking application with
|
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
|
│ │ ├── 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)
|
│ │ ├── 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_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)
|
│ │ ├── generation_task.py # Background asyncio tasks: run_generation (chat, DB flush, titles, intent routing + tool loop) + run_assist_generation (lightweight, no DB)
|
||||||
│ │ ├── tools.py # LLM tool definitions (create_task, create_note, search_notes, CalDAV events) + execute_tool dispatcher
|
│ │ ├── 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
|
│ │ ├── 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
|
│ │ ├── 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
|
│ │ ├── 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
|
│ │ ├── 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
|
- All task sections hidden when empty; marking done removes from all lists
|
||||||
|
|
||||||
### LLM Chat
|
### 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)
|
- Background generation with `GenerationBuffer` (in-memory SSE fan-out, `Last-Event-ID` reconnect, 60s cleanup)
|
||||||
- Stop generation with partial content preservation
|
- Stop generation with partial content preservation
|
||||||
- Note-aware context building: current note + keyword search for related notes + URL fetching
|
- 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
|
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
|
for real-time rendering during streaming. System prompt includes today's date for relative date
|
||||||
resolution. Graceful degradation: models without tool support respond normally.
|
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).
|
- **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
|
LLM tools: `create_event` (with `all_day` and `recurrence` support), `list_events`, `search_events`.
|
||||||
in asyncio executor. Settings UI for CalDAV configuration.
|
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
|
- **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.
|
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:
|
Exposed via `POST /api/notes/suggest-tags` and `POST /api/notes/:id/append-tag`. Integrated in:
|
||||||
|
|||||||
Reference in New Issue
Block a user