065a6f3d1a
Added three new detection patterns beyond the existing keyword checks: 1. Conversation history dumps: messages containing both "user:" and "assistant:" role markers — real user messages never have both 2. Context synthesis phrases: "provided context", "following conversation", "based on the conversation" (OW's query augmentation patterns) 3. Markdown template headers: "### " in messages >200 chars (OW uses section headers in background templates; real mobile messages don't) This closes the gap that caused "Fix CobraTrans ESET" to be created spuriously in Agent Ecosystem from an OW history-dump background request. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
334 lines
12 KiB
Python
334 lines
12 KiB
Python
AGENT_NAMES = {"hodor", "bran", "varys", "sam", "raven", "qyburn", "citadel", "jon", "hermes"}
|
|
|
|
STATUS_PHRASES = {
|
|
"status", "health", "running", "last run", "what did", "when did",
|
|
"show me", "how is", "is it", "is running", "did it run", "output",
|
|
"summary", "report", "check", "monitor", "alive", "up",
|
|
}
|
|
|
|
TASK_PHRASES = {
|
|
"create task", "add task", "add issue", "create issue", "log task",
|
|
"log this", "new task", "new issue", "add to plane", "add to backlog",
|
|
"schedule", "remind", "track", "todo", "to do",
|
|
"job item", "job ticket", "work order",
|
|
}
|
|
|
|
# "plan" removed from TASK_PHRASES — it substring-matches "plane" (the tool name)
|
|
TASK_WORDS = {"plan"}
|
|
|
|
QUERY_PHRASES = {
|
|
"list", "list all", "show all", "what are", "give me", "get",
|
|
"my projects", "all projects", "list projects", "show projects",
|
|
"issues", "tasks", "backlog", "what's in", "whats in", "show issues",
|
|
"list issues", "issues in", "tasks in", "work items", "work item",
|
|
}
|
|
|
|
RESEARCH_PHRASES = {
|
|
"research", "search", "find out", "look up", "what is", "explain",
|
|
"how does", "documentation", "docs",
|
|
}
|
|
|
|
CREATE_PROJECT_PHRASES = {
|
|
"create project", "new project", "make project", "add project",
|
|
"create a project", "new project called", "create project called",
|
|
"start a project", "set up a project",
|
|
"create a plane project", "create plane project",
|
|
"create a new project", "make a new project",
|
|
}
|
|
|
|
# Execute intent — words that signal a request to run/deploy a container
|
|
EXECUTE_VERBS = {"restart", "rebuild", "redeploy", "reload", "deploy"}
|
|
|
|
# Short agent name → Docker stack directory name
|
|
STACK_NAMES = {
|
|
"hodor": "hodor-gateway",
|
|
"bran": "bran-changelog",
|
|
"varys": "varys-monitor",
|
|
"sam": "sam-research",
|
|
"raven": "raven-notify",
|
|
"qyburn": "qyburn-coder",
|
|
"citadel": "citadel-mcp",
|
|
"jon": "jon-snow",
|
|
"hermes": "hermes-cloud",
|
|
"monitoring": "monitoring",
|
|
"netbox": "netbox",
|
|
"plane": "plane",
|
|
"gitea": "gitea",
|
|
"searxng": "searxng",
|
|
}
|
|
|
|
|
|
def classify_intent(message: str) -> str:
|
|
msg = message.lower()
|
|
words = set(msg.split())
|
|
|
|
# Open WebUI background tasks — sent automatically after every chat response.
|
|
# They include the full chat history, so they'd mis-trigger execute/task intents.
|
|
|
|
# Pattern 1: OW meta-task headers and keyword phrases
|
|
if (msg.startswith("### task:") or
|
|
msg.startswith("query:") or
|
|
"chat history" in msg or
|
|
"summarizing the chat" in msg or
|
|
"categorizing the main themes" in msg or
|
|
"follow-up questions" in msg or
|
|
"3-5 word title" in msg or
|
|
"provided context" in msg or
|
|
"following conversation" in msg or
|
|
"based on the conversation" in msg):
|
|
return "planning"
|
|
|
|
# Pattern 2: conversation history dumps — contain role marker pairs.
|
|
# Real user messages never contain both "user:" and "assistant:" labels.
|
|
if "user:" in msg and "assistant:" in msg:
|
|
return "planning"
|
|
if msg.count("user:") >= 2 or msg.count("assistant:") >= 2:
|
|
return "planning"
|
|
|
|
# Pattern 3: OW markdown template headers (### ) in long messages.
|
|
# Real mobile messages don't use markdown section headers.
|
|
if "### " in msg and len(msg) > 200:
|
|
return "planning"
|
|
|
|
# Project creation — check before task/query
|
|
if any(p in msg for p in CREATE_PROJECT_PHRASES):
|
|
return "create_project"
|
|
|
|
# Execute intent — agent/stack name + action verb → queue for approval
|
|
if words & EXECUTE_VERBS and words & set(STACK_NAMES.keys()):
|
|
return "execute"
|
|
if any(v in msg for v in EXECUTE_VERBS) and any(s in msg for s in STACK_NAMES):
|
|
return "execute"
|
|
|
|
# Explicit task phrases — check before query to prevent "add task X" → query mis-route
|
|
if any(p in msg for p in TASK_PHRASES):
|
|
return "task"
|
|
if words & TASK_WORDS:
|
|
return "task"
|
|
|
|
# Plane query — list/show projects or issues (check before task to avoid "plan" ⊂ "plane" bug)
|
|
if ("project" in msg or "projects" in msg) and any(p in msg for p in QUERY_PHRASES):
|
|
return "query"
|
|
if "plane" in words and any(p in msg for p in QUERY_PHRASES):
|
|
return "query"
|
|
# Issue list query — "show issues in X", "list tasks for X", "work items for X"
|
|
# Use word-boundary check (words set) to avoid "in" matching inside "incident" etc.
|
|
has_issue_word = bool(words & ISSUE_KEYWORDS) or any(p in msg for p in ISSUE_PHRASES)
|
|
if has_issue_word and words & {"show", "list", "get", "what", "give", "in", "for"}:
|
|
return "query"
|
|
|
|
# Agent name + status word → status
|
|
if words & AGENT_NAMES:
|
|
if any(p in msg for p in STATUS_PHRASES) or words & {"status", "check", "output", "run"}:
|
|
return "status"
|
|
|
|
# Natural task creation: "add X to [project]"
|
|
if msg.startswith("add ") and " to " in msg:
|
|
return "task"
|
|
# "please add" / "add" anywhere + known project keyword → task
|
|
if "add" in words and any(kw in msg for kw in PROJECT_KEYWORDS):
|
|
return "task"
|
|
|
|
# Generic status signal words
|
|
if any(p in msg for p in STATUS_PHRASES) and words & AGENT_NAMES:
|
|
return "status"
|
|
|
|
# Status if asking purely about the agent ecosystem
|
|
if words & AGENT_NAMES and not any(p in msg for p in {"build", "implement", "create", "make"}):
|
|
return "status"
|
|
|
|
# Research intent → route to smart model
|
|
if any(p in msg for p in RESEARCH_PHRASES):
|
|
return "planning"
|
|
|
|
return "planning"
|
|
|
|
|
|
def extract_agent_name(message: str) -> str | None:
|
|
msg = message.lower()
|
|
for name in AGENT_NAMES:
|
|
if name in msg:
|
|
return name
|
|
return None
|
|
|
|
|
|
def extract_execute_target(message: str) -> tuple[str, str] | None:
|
|
"""Extract (action_type, stack_dir_name) from an execute message.
|
|
|
|
Examples:
|
|
'restart citadel' → ('docker_restart', 'citadel-mcp')
|
|
'rebuild raven' → ('docker_rebuild', 'raven-notify')
|
|
'redeploy hermes' → ('docker_rebuild', 'hermes-cloud')
|
|
|
|
Returns None if action type or stack name cannot be determined.
|
|
"""
|
|
msg = message.lower()
|
|
words = set(msg.split())
|
|
|
|
action_type = None
|
|
if words & {"restart", "reload"}:
|
|
action_type = "docker_restart"
|
|
elif words & {"rebuild", "redeploy", "deploy"}:
|
|
action_type = "docker_rebuild"
|
|
|
|
stack_dir = None
|
|
for short, full in STACK_NAMES.items():
|
|
if short in words:
|
|
stack_dir = full
|
|
break
|
|
|
|
if action_type and stack_dir:
|
|
return (action_type, stack_dir)
|
|
return None
|
|
|
|
|
|
PROJECT_KEYWORDS = {
|
|
"bni": "BNI Scheduler",
|
|
"scheduler": "BNI Scheduler",
|
|
"monitor": "Monitoring",
|
|
"monitoring": "Monitoring",
|
|
"grafana": "Monitoring",
|
|
"maester": "Maester Reports",
|
|
"report": "Maester Reports",
|
|
"csf": "Maester Reports",
|
|
"nist": "Maester Reports",
|
|
"portal": "Nexum Portal",
|
|
"authelia": "Nexum Portal",
|
|
"nexum": "Nexum Portal",
|
|
"general": "General / Admin",
|
|
"admin": "General / Admin",
|
|
"agent": "Agent Ecosystem",
|
|
"ecosystem": "Agent Ecosystem",
|
|
"milkwood": "Milkwood Primary",
|
|
"grgl": "GRGL",
|
|
"private": "Private Clients",
|
|
}
|
|
|
|
|
|
ISSUE_KEYWORDS = {"issue", "issues", "task", "tasks", "backlog", "ticket", "tickets"}
|
|
ISSUE_PHRASES = {"work items", "work item"}
|
|
|
|
|
|
def is_issue_query(message: str) -> bool:
|
|
msg = message.lower()
|
|
return bool(set(msg.split()) & ISSUE_KEYWORDS) or any(p in msg for p in ISSUE_PHRASES)
|
|
|
|
|
|
def extract_task_title(message: str) -> str:
|
|
"""Extract clean task title from natural phrasing like 'add buy milk to General'."""
|
|
import re as _re
|
|
msg = message.strip()
|
|
lower = msg.lower()
|
|
|
|
# Explicit prefixes
|
|
for prefix in ("create task:", "add task:", "log task:", "new task:", "log this:"):
|
|
if lower.startswith(prefix):
|
|
return msg[len(prefix):].strip()
|
|
|
|
# Item noun phrases — "add X a job/work item [description]"
|
|
# Check these before "add X to Y" so the wrong " to " isn't captured
|
|
for noun in (r'\bjob item\b', r'\bwork item\b', r'\bwork order\b', r'\bjob ticket\b'):
|
|
m = _re.search(noun + r'\s*(.*)', lower)
|
|
if m:
|
|
remainder = msg[m.start(1):].strip()
|
|
for filler in ("that we need to ", "that is ", "which is ", "to "):
|
|
if remainder.lower().startswith(filler):
|
|
remainder = remainder[len(filler):]
|
|
# "project, title" leftover → take after the comma
|
|
if "," in remainder:
|
|
remainder = remainder[remainder.index(",") + 1:].strip()
|
|
if remainder.strip():
|
|
return remainder.strip()
|
|
|
|
# "please add X to [project], title" or "add X to [project], title"
|
|
work_msg = msg[7:].strip() if lower.startswith("please ") else msg
|
|
work_lower = work_msg.lower()
|
|
if work_lower.startswith("add ") and " to " in work_lower:
|
|
to_idx = work_lower.rfind(" to ")
|
|
after_to = work_msg[to_idx + 4:]
|
|
if "," in after_to:
|
|
title_candidate = after_to[after_to.index(",") + 1:].strip()
|
|
if title_candidate:
|
|
return title_candidate
|
|
# No comma — what's between "add " and " to [project]" is the title
|
|
before_to = work_msg[4:to_idx].strip()
|
|
for noise in ("a work item", "an issue", "a task", "a job item", "a ticket",
|
|
"an item", "a to-do", "a todo"):
|
|
if before_to.lower().startswith(noise):
|
|
remainder = before_to[len(noise):].strip()
|
|
if remainder:
|
|
return remainder
|
|
if before_to:
|
|
return before_to
|
|
|
|
return msg
|
|
|
|
|
|
def extract_compound_task(message: str) -> str | None:
|
|
"""Extract a task title from compound messages like 'create project X and add [todo item,] Y'.
|
|
Returns the task title or None if no compound task found."""
|
|
lower = message.lower()
|
|
# Split on compound markers
|
|
for marker in ("and add to do item,", "and add todo item,", "and add a task,",
|
|
"and add task,", "and add issue,", "and add to do,",
|
|
"and add to-do,", "and add,"):
|
|
idx = lower.find(marker)
|
|
if idx != -1:
|
|
return message[idx + len(marker):].strip().strip('"\'')
|
|
# Simpler: "and add [title]" — only if no comma separator
|
|
idx = lower.find(" and add ")
|
|
if idx != -1:
|
|
candidate = message[idx + 9:].strip()
|
|
# Skip if it looks like a project destination ("and add to X")
|
|
if not candidate.lower().startswith("to "):
|
|
return candidate.strip('"\'')
|
|
return None
|
|
|
|
|
|
def _trim_at_compound(text: str) -> str:
|
|
"""Trim text at compound boundaries like 'and add'."""
|
|
lower = text.lower()
|
|
for stop in (" and add", " then add", ", and", " also add"):
|
|
idx = lower.find(stop)
|
|
if idx != -1:
|
|
text = text[:idx]
|
|
return text.strip().strip('"\',.')
|
|
|
|
|
|
def extract_new_project_name(message: str) -> str | None:
|
|
"""Extract project name from 'create project called X', 'new project: X', etc."""
|
|
msg = message.strip()
|
|
lower = msg.lower()
|
|
for marker in ("called ", "named ", "name ", ": ", "- "):
|
|
idx = lower.find(marker)
|
|
if idx != -1:
|
|
name = _trim_at_compound(msg[idx + len(marker):])
|
|
if name:
|
|
return name
|
|
for prefix in ("create a new project ", "create a plane project ", "create plane project ",
|
|
"create a project ", "create project ", "new project ", "make a new project ",
|
|
"make project ", "add project ", "start a project ", "set up a project "):
|
|
if lower.startswith(prefix):
|
|
name = _trim_at_compound(msg[len(prefix):])
|
|
if name:
|
|
return name
|
|
return None
|
|
|
|
|
|
def extract_task_destination(message: str) -> str | None:
|
|
"""Extract the destination project from 'add X to Y' → returns Y, or None."""
|
|
lower = message.strip().lower()
|
|
if lower.startswith("add ") and " to " in lower:
|
|
parts = message.strip().rsplit(" to ", 1)
|
|
if len(parts) == 2:
|
|
return parts[1].strip()
|
|
return None
|
|
|
|
|
|
def extract_project_name(message: str) -> str | None:
|
|
msg = message.lower()
|
|
for kw, project in PROJECT_KEYWORDS.items():
|
|
if kw in msg:
|
|
return project
|
|
return None
|