"""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= 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=. - "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=. NEVER use this for tasks. - "delete", "remove", "trash", "get rid of" a task → use delete_task with query=. NEVER use this for notes. - "read", "open", "show me", "what does X say", "display", "pull up" a specific note → use get_note with query=. - "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