Phase 3b: Command parsing, AI context, player instructions
- CommandParser: regex-based parsing for follow, stop, go, chest, craft, mine, give, attack, look commands - Commands converted to high-priority tasks in the brain's queue - AI prompt now includes real-time context: current action, health, nearby players, hostiles, time of day - Doug answers "what are you doing?" truthfully based on actual state - Player commands get personality-appropriate AI acknowledgments - Brain's wants_ai_chat signal wired for unprompted chat Supported commands: "Doug, follow me" → follow_player task "Doug, stop" → stop task "Doug, open that chest" → open_chest task "Doug, sort the chest" → sort_chest task "Doug, craft a pickaxe" → craft_item task "Doug, mine some stone" → find + dig task "Doug, attack that" → attack_nearest_hostile task Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b609d4c896
commit
be4476ce4d
3 changed files with 395 additions and 4 deletions
|
|
@ -98,15 +98,33 @@ def build_system_prompt(
|
|||
# Custom notes
|
||||
custom = custom_notes.strip() if custom_notes else ""
|
||||
|
||||
# Context — what Doug is currently doing/seeing
|
||||
context_line = ""
|
||||
if context:
|
||||
ctx_parts = []
|
||||
if context.get("current_action") and context["current_action"] != "idle":
|
||||
ctx_parts.append(f"Currently: {context['current_action']}")
|
||||
if context.get("health", 20) < 10:
|
||||
ctx_parts.append(f"Low health ({context['health']})")
|
||||
if context.get("nearby_players"):
|
||||
ctx_parts.append(f"With: {', '.join(context['nearby_players'])}")
|
||||
if context.get("time_of_day") == "night":
|
||||
ctx_parts.append("It's night")
|
||||
if context.get("nearby_hostiles"):
|
||||
ctx_parts.append(f"Mobs nearby: {', '.join(context['nearby_hostiles'][:2])}")
|
||||
if ctx_parts:
|
||||
context_line = f"Right now: {'. '.join(ctx_parts)}."
|
||||
|
||||
# Build the prompt — keep it SHORT
|
||||
parts = [
|
||||
identity,
|
||||
personality,
|
||||
lang,
|
||||
custom,
|
||||
context_line,
|
||||
"",
|
||||
"Rules: Reply in ONE short sentence. Under 15 words. Talk like a normal person.",
|
||||
"NEVER make up activities. If asked what you are doing, say not much or just hanging out.",
|
||||
"If asked what you are doing, answer based on your current activity above.",
|
||||
"Plain text only. Do not start with your name.",
|
||||
]
|
||||
|
||||
|
|
|
|||
326
dougbot/core/command_parser.py
Normal file
326
dougbot/core/command_parser.py
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
"""
|
||||
Parse player chat messages into actionable commands for Doug.
|
||||
Uses keyword matching for common commands and falls back to AI for complex requests.
|
||||
"""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from dougbot.core.task_queue import Task, Priority
|
||||
from dougbot.utils.logging import get_logger
|
||||
|
||||
log = get_logger("core.commands")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedCommand:
|
||||
"""A command extracted from a chat message."""
|
||||
action: str # What to do
|
||||
target: str = "" # Who/what it's about
|
||||
params: dict = None # Extra parameters
|
||||
raw_message: str = "" # Original message
|
||||
confidence: float = 1.0 # How sure we are (1.0 = keyword match, <1.0 = fuzzy)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.params is None:
|
||||
self.params = {}
|
||||
|
||||
|
||||
class CommandParser:
|
||||
"""Parse player messages into commands."""
|
||||
|
||||
# Movement commands
|
||||
FOLLOW_PATTERNS = [
|
||||
r"(?:come|follow)\s+(?:me|here|over here)",
|
||||
r"(?:come|walk|go)\s+(?:to|over)\s+(?:me|here)",
|
||||
r"(?:follow)\s+(?:me|along)",
|
||||
r"(?:over here|come here)",
|
||||
]
|
||||
|
||||
STOP_PATTERNS = [
|
||||
r"^stop",
|
||||
r"(?:stop|halt|wait|stay|freeze|don'?t move)",
|
||||
r"stand\s+still",
|
||||
]
|
||||
|
||||
GO_PATTERNS = [
|
||||
r"(?:go|walk|move|run|head)\s+(?:to|toward|towards|over to)\s+(.+)",
|
||||
r"(?:go|walk|move)\s+(?:north|south|east|west|forward|back|left|right)",
|
||||
]
|
||||
|
||||
# Interaction commands
|
||||
CHEST_PATTERNS = [
|
||||
r"(?:open|check|look in|look at|inspect)\s+(?:that|this|the)?\s*(?:chest|barrel|container|box)",
|
||||
r"(?:sort|organize|clean)\s+(?:that|this|the)?\s*(?:chest|barrel|container|box|inventory)",
|
||||
r"what'?s?\s+in\s+(?:that|this|the)\s+(?:chest|barrel|container)",
|
||||
]
|
||||
|
||||
CRAFT_PATTERNS = [
|
||||
r"(?:craft|make|build|create)\s+(?:a\s+|an\s+|some\s+)?(.+)",
|
||||
]
|
||||
|
||||
MINE_PATTERNS = [
|
||||
r"(?:mine|dig|break|destroy)\s+(?:that|this|the)?\s*(.+)",
|
||||
]
|
||||
|
||||
GIVE_PATTERNS = [
|
||||
r"(?:give|hand|pass|toss)\s+(?:me|us)\s+(?:a\s+|an\s+|some\s+)?(.+)",
|
||||
]
|
||||
|
||||
# Social commands
|
||||
ATTACK_PATTERNS = [
|
||||
r"(?:attack|fight|kill|hit)\s+(?:that|this|the)?\s*(.+)",
|
||||
]
|
||||
|
||||
LOOK_PATTERNS = [
|
||||
r"(?:look|look at|check out)\s+(?:that|this|the|over there|around)",
|
||||
]
|
||||
|
||||
def __init__(self, doug_name: str):
|
||||
self._doug_name = doug_name
|
||||
self._short_name = doug_name.lower().split("-")[0].split("_")[0]
|
||||
|
||||
def parse(self, sender: str, message: str) -> Optional[ParsedCommand]:
|
||||
"""Parse a chat message into a command, if it's directed at Doug."""
|
||||
msg = message.lower().strip()
|
||||
|
||||
# Remove Doug's name from the beginning if present
|
||||
for prefix in [f"{self._doug_name.lower()},", f"{self._doug_name.lower()} ",
|
||||
f"{self._short_name},", f"{self._short_name} "]:
|
||||
if msg.startswith(prefix):
|
||||
msg = msg[len(prefix):].strip()
|
||||
break
|
||||
|
||||
# Try each command pattern
|
||||
cmd = self._try_follow(msg, sender)
|
||||
if cmd: return cmd
|
||||
|
||||
cmd = self._try_stop(msg, sender)
|
||||
if cmd: return cmd
|
||||
|
||||
cmd = self._try_go(msg, sender)
|
||||
if cmd: return cmd
|
||||
|
||||
cmd = self._try_chest(msg, sender)
|
||||
if cmd: return cmd
|
||||
|
||||
cmd = self._try_craft(msg, sender)
|
||||
if cmd: return cmd
|
||||
|
||||
cmd = self._try_mine(msg, sender)
|
||||
if cmd: return cmd
|
||||
|
||||
cmd = self._try_give(msg, sender)
|
||||
if cmd: return cmd
|
||||
|
||||
cmd = self._try_attack(msg, sender)
|
||||
if cmd: return cmd
|
||||
|
||||
cmd = self._try_look(msg, sender)
|
||||
if cmd: return cmd
|
||||
|
||||
# No keyword match — return None (caller can use AI to interpret)
|
||||
return None
|
||||
|
||||
def _try_follow(self, msg: str, sender: str) -> Optional[ParsedCommand]:
|
||||
for pattern in self.FOLLOW_PATTERNS:
|
||||
if re.search(pattern, msg, re.IGNORECASE):
|
||||
return ParsedCommand(
|
||||
action="follow_player",
|
||||
target=sender,
|
||||
raw_message=msg,
|
||||
)
|
||||
return None
|
||||
|
||||
def _try_stop(self, msg: str, sender: str) -> Optional[ParsedCommand]:
|
||||
for pattern in self.STOP_PATTERNS:
|
||||
if re.search(pattern, msg, re.IGNORECASE):
|
||||
return ParsedCommand(action="stop", raw_message=msg)
|
||||
return None
|
||||
|
||||
def _try_go(self, msg: str, sender: str) -> Optional[ParsedCommand]:
|
||||
for pattern in self.GO_PATTERNS:
|
||||
match = re.search(pattern, msg, re.IGNORECASE)
|
||||
if match:
|
||||
target = match.group(1) if match.lastindex else ""
|
||||
return ParsedCommand(
|
||||
action="go_to",
|
||||
target=target.strip(),
|
||||
raw_message=msg,
|
||||
)
|
||||
return None
|
||||
|
||||
def _try_chest(self, msg: str, sender: str) -> Optional[ParsedCommand]:
|
||||
for pattern in self.CHEST_PATTERNS:
|
||||
if re.search(pattern, msg, re.IGNORECASE):
|
||||
is_sort = any(w in msg for w in ["sort", "organize", "clean"])
|
||||
return ParsedCommand(
|
||||
action="sort_chest" if is_sort else "check_chest",
|
||||
raw_message=msg,
|
||||
)
|
||||
return None
|
||||
|
||||
def _try_craft(self, msg: str, sender: str) -> Optional[ParsedCommand]:
|
||||
for pattern in self.CRAFT_PATTERNS:
|
||||
match = re.search(pattern, msg, re.IGNORECASE)
|
||||
if match:
|
||||
item = match.group(1).strip() if match.lastindex else ""
|
||||
# Normalize item name
|
||||
item = item.replace(" ", "_").lower()
|
||||
return ParsedCommand(
|
||||
action="craft",
|
||||
target=item,
|
||||
raw_message=msg,
|
||||
)
|
||||
return None
|
||||
|
||||
def _try_mine(self, msg: str, sender: str) -> Optional[ParsedCommand]:
|
||||
for pattern in self.MINE_PATTERNS:
|
||||
match = re.search(pattern, msg, re.IGNORECASE)
|
||||
if match:
|
||||
target = match.group(1).strip() if match.lastindex else ""
|
||||
return ParsedCommand(
|
||||
action="mine",
|
||||
target=target.replace(" ", "_").lower(),
|
||||
raw_message=msg,
|
||||
)
|
||||
return None
|
||||
|
||||
def _try_give(self, msg: str, sender: str) -> Optional[ParsedCommand]:
|
||||
for pattern in self.GIVE_PATTERNS:
|
||||
match = re.search(pattern, msg, re.IGNORECASE)
|
||||
if match:
|
||||
item = match.group(1).strip() if match.lastindex else ""
|
||||
return ParsedCommand(
|
||||
action="give",
|
||||
target=item.replace(" ", "_").lower(),
|
||||
params={"recipient": sender},
|
||||
raw_message=msg,
|
||||
)
|
||||
return None
|
||||
|
||||
def _try_attack(self, msg: str, sender: str) -> Optional[ParsedCommand]:
|
||||
for pattern in self.ATTACK_PATTERNS:
|
||||
match = re.search(pattern, msg, re.IGNORECASE)
|
||||
if match:
|
||||
target = match.group(1).strip() if match.lastindex else ""
|
||||
return ParsedCommand(
|
||||
action="attack",
|
||||
target=target,
|
||||
raw_message=msg,
|
||||
)
|
||||
return None
|
||||
|
||||
def _try_look(self, msg: str, sender: str) -> Optional[ParsedCommand]:
|
||||
for pattern in self.LOOK_PATTERNS:
|
||||
if re.search(pattern, msg, re.IGNORECASE):
|
||||
return ParsedCommand(action="look_around", raw_message=msg)
|
||||
return None
|
||||
|
||||
|
||||
def command_to_task(cmd: ParsedCommand, behaviors) -> Optional[Task]:
|
||||
"""Convert a parsed command into a Task for the queue."""
|
||||
|
||||
if cmd.action == "follow_player":
|
||||
return Task(
|
||||
name=f"follow_{cmd.target}",
|
||||
priority=Priority.HIGH,
|
||||
action="follow_player",
|
||||
params={"name": cmd.target, "range": 3},
|
||||
description=f"Following {cmd.target}",
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
elif cmd.action == "stop":
|
||||
return Task(
|
||||
name="stop",
|
||||
priority=Priority.HIGH,
|
||||
action="stop",
|
||||
params={},
|
||||
description="Stopping",
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
elif cmd.action == "check_chest":
|
||||
# Find nearest container
|
||||
if behaviors.nearby_containers:
|
||||
container = behaviors.nearby_containers[0]
|
||||
return Task(
|
||||
name="check_chest",
|
||||
priority=Priority.HIGH,
|
||||
action="open_chest",
|
||||
params=container["position"],
|
||||
description="Checking the chest",
|
||||
timeout=15,
|
||||
callback="on_container_opened",
|
||||
)
|
||||
|
||||
elif cmd.action == "sort_chest":
|
||||
if behaviors.nearby_containers:
|
||||
container = behaviors.nearby_containers[0]
|
||||
return Task(
|
||||
name="sort_chest",
|
||||
priority=Priority.HIGH,
|
||||
action="open_chest",
|
||||
params=container["position"],
|
||||
description="Sorting the chest",
|
||||
timeout=30,
|
||||
callback="on_sort_container",
|
||||
)
|
||||
|
||||
elif cmd.action == "craft":
|
||||
return Task(
|
||||
name=f"craft_{cmd.target}",
|
||||
priority=Priority.HIGH,
|
||||
action="craft_item",
|
||||
params={"itemName": cmd.target, "count": 1},
|
||||
description=f"Crafting {cmd.target.replace('_', ' ')}",
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
elif cmd.action == "mine":
|
||||
return Task(
|
||||
name=f"mine_{cmd.target}",
|
||||
priority=Priority.HIGH,
|
||||
action="find_blocks",
|
||||
params={"blockName": cmd.target, "radius": 16, "count": 1},
|
||||
description=f"Looking for {cmd.target.replace('_', ' ')} to mine",
|
||||
timeout=30,
|
||||
callback="on_found_blocks_to_mine",
|
||||
)
|
||||
|
||||
elif cmd.action == "attack":
|
||||
return Task(
|
||||
name="attack_command",
|
||||
priority=Priority.HIGH,
|
||||
action="attack_nearest_hostile",
|
||||
params={"range": 8},
|
||||
description=f"Attacking!",
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
elif cmd.action == "look_around":
|
||||
return Task(
|
||||
name="look_around_command",
|
||||
priority=Priority.HIGH,
|
||||
action="scan_surroundings",
|
||||
params={"radius": 16},
|
||||
description="Looking around",
|
||||
timeout=10,
|
||||
callback="on_look_around_report",
|
||||
)
|
||||
|
||||
elif cmd.action == "go_to":
|
||||
# For named destinations, we'd need a memory system
|
||||
# For now, try to interpret as a player name
|
||||
return Task(
|
||||
name=f"go_to_{cmd.target}",
|
||||
priority=Priority.HIGH,
|
||||
action="follow_player",
|
||||
params={"name": cmd.target, "range": 3},
|
||||
description=f"Going to {cmd.target}",
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
return None
|
||||
|
|
@ -16,6 +16,7 @@ from dougbot.db.models import DougModel, PersonaConfig
|
|||
from dougbot.bridge.node_manager import NodeManager
|
||||
from dougbot.bridge.ws_client import BridgeWSClient
|
||||
from dougbot.core.brain import DougBrain
|
||||
from dougbot.core.command_parser import CommandParser, command_to_task
|
||||
from dougbot.ai.ollama_client import OllamaClient
|
||||
from dougbot.ai.prompt_builder import build_system_prompt
|
||||
from dougbot.utils.logging import get_logger
|
||||
|
|
@ -383,7 +384,11 @@ class MainWindow(QMainWindow):
|
|||
|
||||
# Check if message is directed at Doug
|
||||
if self._active_doug and self._should_respond(message):
|
||||
self._generate_response(sender, message)
|
||||
# Try command parsing first
|
||||
handled = self._try_command(sender, message)
|
||||
if not handled:
|
||||
# Fall back to AI chat response
|
||||
self._generate_response(sender, message)
|
||||
|
||||
elif event == "player_joined":
|
||||
username = data.get("username", "Unknown")
|
||||
|
|
@ -405,13 +410,41 @@ class MainWindow(QMainWindow):
|
|||
if self._brain:
|
||||
self._brain.update_from_event(event, data)
|
||||
|
||||
# ── Command Parsing ──
|
||||
|
||||
def _try_command(self, sender: str, message: str) -> bool:
|
||||
"""Try to parse a chat message as a command. Returns True if handled."""
|
||||
if not self._active_doug or not self._brain:
|
||||
return False
|
||||
|
||||
parser = CommandParser(self._active_doug.name)
|
||||
cmd = parser.parse(sender, message)
|
||||
if not cmd:
|
||||
return False
|
||||
|
||||
# Convert command to task
|
||||
task = command_to_task(cmd, self._brain._behaviors)
|
||||
if not task:
|
||||
return False
|
||||
|
||||
# Add task to brain's queue
|
||||
self._brain._tasks.add(task)
|
||||
log.info(f"Command from {sender}: {cmd.action} → task '{task.name}'")
|
||||
|
||||
# Generate a personality-appropriate acknowledgment via AI
|
||||
ack_prompt = (
|
||||
f"{sender} asked you to: {message}. "
|
||||
f"You understood and are now doing it. Acknowledge briefly."
|
||||
)
|
||||
self._generate_response("SYSTEM", f"[Command: {cmd.action}] {ack_prompt}")
|
||||
return True
|
||||
|
||||
# ── Brain Chat ──
|
||||
|
||||
def _on_brain_wants_chat(self, context: str, prompt: str):
|
||||
"""Brain wants Doug to say something unprompted."""
|
||||
if not self._active_doug:
|
||||
return
|
||||
# Use AI to generate what Doug says
|
||||
self._generate_response("SYSTEM", f"[Context: {context}] {prompt}")
|
||||
|
||||
# ── Chat AI ──
|
||||
|
|
@ -453,9 +486,23 @@ class MainWindow(QMainWindow):
|
|||
|
||||
self.dashboard.log_viewer.append_system(f"Thinking... ({sender} said: {message[:50]})")
|
||||
|
||||
# Build prompt
|
||||
# Build context from brain state
|
||||
ai_context = None
|
||||
if self._brain:
|
||||
b = self._brain._behaviors
|
||||
ai_context = {
|
||||
"current_action": self._brain.current_action,
|
||||
"health": b.health,
|
||||
"food": b.food,
|
||||
"nearby_players": [p["name"] for p in b.nearby_players],
|
||||
"nearby_hostiles": [h["type"] for h in b.nearby_hostiles[:3]],
|
||||
"time_of_day": "night" if b.is_night else "day",
|
||||
}
|
||||
|
||||
# Build prompt with context
|
||||
system_prompt = build_system_prompt(
|
||||
name=doug.name, age=doug.age, persona=persona,
|
||||
context=ai_context,
|
||||
custom_notes=doug.custom_notes,
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue