Files
FabledScribe/src/fabledassistant/services/intent.py
T
bvandeusen 590682a5d2 Phase 22: SearXNG web research pipeline + settings layout overhaul
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>
2026-02-27 15:21:38 -05:00

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