Files
FabledGrimoire/src/fabledassistant/services/intent.py
T
bvandeusenandClaude Sonnet 4.6 4998d04739 Fix search_web over-triggering when user references existing notes
Add a Python fast-path regex (_PRIOR_WORK_REFS) in classify_intent that
detects phrases like "research you did", "note you made", "using your
research", "based on the research" etc. and returns no-tool immediately —
saving the 19s intent LLM call and correctly letting the main model answer
using search_notes/context rather than firing off a web search.

Also tighten the intent prompt rules for search_web: explicitly prohibit
using it for creative/brainstorming requests or when the user references
existing notes, and add a rule that creative/ideation questions ("think of",
"come up with", "brainstorm") always route to null (chat).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 18:12:58 -05:00

278 lines
13 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 explicitly wants a quick factual answer from the web — current events, version
numbers, real-time facts ("what is the latest version of X", "who won the game last night").
ONLY use when the information is clearly not in their notes and they need something fresh from
the internet. Do NOT use for creative questions, brainstorming, game design, writing help, or
when the user is building on content they already have. Do NOT use when the user references
their own notes or prior research — use search_notes instead.
- research_topic: user wants a comprehensive, multi-section research note created from web sources.
Use whenever the user wants to deeply understand, learn about, or get a full written reference
on any subject — regardless of how they phrase it. The topic can be anything: technical subjects,
shopping decisions, comparisons, how things work, historical topics, etc.
The "topic" argument should capture the full subject matter of the request.
Prefer this over search_web when the user's request implies wanting thorough coverage rather than
a quick answer.
- For creative/ideation requests ("think of", "come up with", "ideas for", "help me design",
"imagine", "brainstorm") use null (chat) — the main model answers these directly. Only route
to a tool if the user also explicitly asks to search, look something up, or create/save something.
- If the user references notes or research the assistant previously created, prefer search_notes
(to retrieve the relevant note) or null (chat) so the main model can use its tools to find it.
Never use search_web when existing notes likely contain the answer.
- "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."""
# Fast-path: "Research: <topic>" sent by the Research button.
_RESEARCH_PREFIX = re.compile(r"^[Rr]esearch:\s+(.+)", re.DOTALL)
# When the user refers to work the assistant previously did, they want the main
# model to answer using existing context — not a web search. Skip intent and
# fall through to the streaming path so the model can call search_notes itself.
_PRIOR_WORK_REFS = re.compile(
r"\b("
r"research (you|that you|we) did"
r"|research (you|that you|we) (made|created|compiled|wrote)"
r"|note (you|that you|we) (made|created|wrote)"
r"|notes (you|that you|we) (made|created|wrote)"
r"|using (your|the) research"
r"|based on (your|the) research"
r"|from (your|the) (research|note|notes)"
r")\b",
re.IGNORECASE,
)
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()
# Fast-path: "Research: <topic>" is the canonical format sent by the Research
# button in the UI. It always means research_topic — skip the LLM call entirely.
valid_names = {t.get("function", {}).get("name") for t in tools}
if "research_topic" in valid_names:
m = _RESEARCH_PREFIX.match(user_message.strip())
if m:
topic = m.group(1).strip()
logger.info("Intent fast-path: 'Research:' prefix → research_topic, topic='%s'", topic[:80])
return IntentResult(
tool_name="research_topic",
arguments={"topic": topic},
confidence="high",
ack=f"I'll research that and compile a comprehensive note.",
)
# Fast-path: user references prior assistant work ("research you did", "note you
# made", etc.) — this is a request to use existing content, not search the web.
# Return no-tool so the main model answers conversationally with search_notes
# available if it needs to retrieve the note.
if _PRIOR_WORK_REFS.search(user_message):
logger.info(
"Intent fast-path: prior-work reference detected → skipping tool dispatch"
)
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, num_ctx=4096)
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