Files
jon-snow/app/intent.py
T
admin 59c9cb837d fix: task intent classifier, Hermes brain, rolling run log
- Intent classifier: task phrases now checked before query to prevent
  "add task X" mis-routing; "job item"/"job ticket"/"work order" added
  to TASK_PHRASES; "please add + project keyword" fallback added;
  substring match bug fixed ("in" inside "incident" triggered query)
- brain.py: routes planning fallback to Hermes cloud (claude-sonnet-4-6)
  via HERMES_URL/HERMES_API_KEY env vars; falls back to local Ollama
  if Hermes is unavailable
- main.py: rolling 50-run log written to logs/jon-snow/runs.jsonl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 14:03:29 +00:00

292 lines
10 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.
# Patterns: "### Task:", "Query: <text>" (query augmentation), meta-task keywords.
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):
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()
if lower.startswith("add ") and " to " in lower:
parts = msg.rsplit(" to ", 1)
title = parts[0][4:].strip()
return title if title else msg
for prefix in ("create task:", "add task:", "log task:", "new task:", "log this:"):
if lower.startswith(prefix):
return msg[len(prefix):].strip()
# "add [who] a job item [description]" — extract description after "job item"
m = _re.search(r'\bjob item\b\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):]
if remainder.strip():
return remainder.strip()
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