590682a5d2
Research pipeline (research_topic tool): - New service: services/research.py — sub-query generation, SearXNG search, URL fetch, deduplication, and LLM synthesis into a note - 5 sub-queries × 3 pages = up to 15 sources, capped at 12 for synthesis - Synthesis uses num_ctx=16384 + max_tokens=8192 for long-form output - Prompt demands 2500+ words, 6+ topic-appropriate sections, detailed prose - 429 retry with backoff; 1s inter-query sleep; raw_decode JSON parsing search_web tool (new): - Lightweight single-query SearXNG search, results returned inline in chat - LLM answers conversationally in round 1; no note created - web_search result type with external links in ToolCallCard Infrastructure: - llm.py: generate_completion accepts num_ctx override - config.py: SEARXNG_URL + Config.searxng_enabled() - docker-compose: OLLAMA_NUM_PARALLEL=2, commented SEARXNG_URL example - intent.py: search_web and research_topic routing rules Settings UI: - 2-column grid layout (small sections pair up, complex span full width) - Search Test section: live SearXNG query with result preview - GET /api/settings/search?q= proxy endpoint - Research button (magnifier) in ChatView input toolbar → popover modal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
220 lines
10 KiB
Python
220 lines
10 KiB
Python
"""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)
|
|
confidence: str = "high" # "high", "medium", or "low"
|
|
ack: str | None = None # One-sentence acknowledgment to stream immediately
|
|
|
|
@property
|
|
def should_execute(self) -> bool:
|
|
"""True if a tool was identified with sufficient confidence."""
|
|
return self.tool_name is not None and self.confidence != "low"
|
|
|
|
|
|
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 (and recent conversation \
|
|
history for context), 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": {{...}}, "confidence": "high"|"medium"|"low", "ack": "One short sentence describing what you're about to do."}}
|
|
- If it's general chat: {{"tool": null, "confidence": "high", "ack": null}}
|
|
|
|
Confidence levels:
|
|
- "high": the intent is clear and all required arguments are unambiguous
|
|
- "medium": probably requires the tool but some argument is uncertain or inferred
|
|
- "low": uncertain whether this needs a tool at all, or the message is too ambiguous to act on
|
|
|
|
Rules:
|
|
- Use recent conversation history to resolve references like "it", "that event", "the meeting", "move it", etc.
|
|
- 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".
|
|
- "add to", "update", "edit", "expand", "flesh out", "modify", "append to", "continue writing" a note → use update_note with query=<note title from context> and mode="append" for additions or mode="replace" for full rewrites.
|
|
- "mark as done", "complete", "finish", "mark in progress", "start" a task → use update_note with status field.
|
|
- "set priority", "change priority", "make it high priority" → use update_note with priority field.
|
|
- "set due date", "move due date", "due on Friday" for a task → use update_note with due_date field.
|
|
- "what are my overdue tasks", "show overdue", "tasks due today", "high priority tasks", "in progress tasks", "what's due this week" → use list_tasks with appropriate status/priority/due_before/due_after filters. For overdue, set due_before to today's date.
|
|
- If a note was created earlier in the conversation and the user provides more content for it, use update_note (not create_note).
|
|
- Use create_note ONLY when the user explicitly wants a brand new note that doesn't already exist.
|
|
- "update", "change", "rename", "set due date on" a CalDAV todo → use update_todo.
|
|
- "update", "change", "move", "reschedule" an event → use update_event.
|
|
- "delete", "cancel", "remove" an event → use delete_event.
|
|
- "which calendars", "list calendars", "my calendars" → use list_calendars.
|
|
- "create a calendar todo", "add a CalDAV todo" → use create_todo. Default to create_task for general tasks unless the user explicitly mentions CalDAV or calendar todo.
|
|
- "show calendar todos", "list calendar todos" → use list_todos.
|
|
- "find calendar todo", "search calendar todos", "find todo named X" → use search_todos with query=<keyword>.
|
|
- "completed/finished calendar todo" → use complete_todo.
|
|
- "delete/remove calendar todo" → use delete_todo.
|
|
- "remind me X minutes/hours before" an event → convert to reminder_minutes parameter on create_event.
|
|
- When the user asks about events in a time period (e.g. "events in September", "what's on next week", "my schedule for March"), use list_events with date_from/date_to covering that period. Do NOT use search_events for time-based queries — search_events is only for keyword matching against event titles.
|
|
- "delete", "remove", "trash", "get rid of" a note (not a task) → use delete_note with query=<note name/keyword>. NEVER use this for tasks.
|
|
- "delete", "remove", "trash", "get rid of" a task → use delete_task with query=<task name/keyword>. NEVER use this for notes.
|
|
- "read", "open", "show me", "what does X say", "display", "pull up" a specific note → use get_note with query=<note name>.
|
|
- "list my notes", "show notes", "recent notes", "browse notes", "notes tagged X" → use list_notes (with optional q or tags).
|
|
- "tag X with Y", "add tag Y to X", "untag Y from X", "remove tag Y from X" → use update_note with tags=[Y] and tag_mode="add" or "remove".
|
|
- search_web: user wants a quick web search to answer a factual question
|
|
("search for X", "look up X", "what is the latest version of X", "find X online",
|
|
"google X", "what is X" for quick factual answers — NOT when they want a comprehensive note)
|
|
- research_topic: user wants to research a topic and create a comprehensive note from web sources
|
|
("research X", "research X and make a note", "compile notes on X", "write a report on X",
|
|
"deep dive into X", "find everything about X", "comprehensive guide to X")
|
|
- "ack": one short, natural sentence confirming the action (tool path only). Vary phrasing — do not always start with "Let me". Omit (null) for chat-only responses.
|
|
- Do NOT wrap the JSON in markdown code fences."""
|
|
|
|
|
|
async def classify_intent(
|
|
user_message: str,
|
|
tools: list[dict],
|
|
model: str,
|
|
history: list[dict] | None = None,
|
|
) -> IntentResult:
|
|
"""Classify user intent via a fast non-streaming LLM call.
|
|
|
|
history is a list of recent {role, content} messages (user/assistant only,
|
|
no system messages) for resolving anaphoric references.
|
|
|
|
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: list[dict] = [
|
|
{
|
|
"role": "system",
|
|
"content": _SYSTEM_PROMPT_TEMPLATE.format(
|
|
today=today, tool_summary=tool_summary
|
|
),
|
|
},
|
|
]
|
|
|
|
# Inject recent history turns so the model can resolve references
|
|
if history:
|
|
for turn in history:
|
|
role = turn.get("role")
|
|
content = turn.get("content", "")
|
|
if role in ("user", "assistant") and content:
|
|
messages.append({"role": role, "content": content})
|
|
|
|
messages.append({"role": "user", "content": user_message})
|
|
|
|
try:
|
|
raw = await generate_completion(messages, model, max_tokens=350)
|
|
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")
|
|
confidence = parsed.get("confidence", "high")
|
|
if confidence not in ("high", "medium", "low"):
|
|
confidence = "high"
|
|
|
|
if tool_name is None:
|
|
return IntentResult(confidence=confidence)
|
|
|
|
# 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 = {}
|
|
|
|
ack = parsed.get("ack") or None
|
|
if ack is not None:
|
|
ack = ack.strip() or None
|
|
|
|
logger.info(
|
|
"Intent classified: tool=%s, confidence=%s, args=%s",
|
|
tool_name, confidence, json.dumps(arguments)[:200],
|
|
)
|
|
return IntentResult(tool_name=tool_name, arguments=arguments, confidence=confidence, ack=ack)
|
|
|
|
|
|
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
|