Add training audit logger, open sudo for playtesting

- Structured JSONL audit log for every pray/sudo interaction
- Bug_log feedback linked to last interaction with trust-level tagging
- sudo_allow_all_players config flag for playtest mode (enabled)
- training_audit_path config key (/var/log/mc_training_audit.jsonl)
- Deployed to CT 644 paper-ai server

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Code
2026-03-18 13:38:25 -04:00
parent ba4a2f4262
commit 1e9bda4a15
2 changed files with 260 additions and 6 deletions
+2
View File
@@ -37,7 +37,9 @@
"debug_commands": true, "debug_commands": true,
"sudo_enabled": true, "sudo_enabled": true,
"sudo_user": "slingshooter08", "sudo_user": "slingshooter08",
"sudo_allow_all_players": true,
"sudo_max_commands": 12, "sudo_max_commands": 12,
"training_audit_path": "/var/log/mc_training_audit.jsonl",
"bug_log_path": "/var/log/mc_aigod_paper_bug.log", "bug_log_path": "/var/log/mc_aigod_paper_bug.log",
"bug_log_event_lines": 40, "bug_log_event_lines": 40,
"bug_log_raw_lines": 120, "bug_log_raw_lines": 120,
+258 -6
View File
@@ -139,6 +139,12 @@ def _send_private(player: str, text: str, config, color: str = "gray", italic: b
) )
def _sudo_trace(player: str, text: str, config, color: str = "dark_gray"):
if not bool(config.get("sudo_trace_commands", True)):
return
_send_private(player, text, config, color=color)
def _template_path(config) -> str: def _template_path(config) -> str:
return config.get("template_dir", DEFAULT_TEMPLATE_DIR) return config.get("template_dir", DEFAULT_TEMPLATE_DIR)
@@ -1057,7 +1063,8 @@ def _service_log_path(config) -> str:
def _read_recent_service_action_lines(path: str, max_lines: int) -> list: def _read_recent_service_action_lines(path: str, max_lines: int) -> list:
lines = deque(maxlen=max_lines * 8) lines = deque(maxlen=max_lines * 8)
keep = re.compile( keep = re.compile(
r'Prayer from|SUDO from|Executing RCON:|SUDO execute:|RCON result:|SUDO result:|BUG_LOG from' r'Prayer from|SUDO from|Executing RCON:|SUDO execute:|RCON result:|SUDO result:|BUG_LOG from|'
r'Gateway god_system|God intervenes unprompted|Blocked tp in unprompted intervention'
) )
try: try:
with open(path, 'r', encoding='utf-8', errors='replace') as f: with open(path, 'r', encoding='utf-8', errors='replace') as f:
@@ -1070,6 +1077,32 @@ def _read_recent_service_action_lines(path: str, max_lines: int) -> list:
return list(lines)[-max_lines:] return list(lines)[-max_lines:]
def _read_recent_intervention_lines(path: str, max_lines: int) -> list:
"""Return focused history of unprompted intervention cycles."""
out = deque(maxlen=max_lines * 6)
start = re.compile(r'Gateway god_system|God intervenes unprompted')
detail = re.compile(
r'Blocked tp in unprompted intervention|Executing RCON:|RCON result:|Next divine intervention'
)
window = 0
try:
with open(path, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
clean = line.rstrip('\n')
if start.search(clean):
out.append(clean)
window = 14
continue
if window > 0 and detail.search(clean):
out.append(clean)
window -= 1
elif window > 0:
window -= 1
except Exception as e:
return [f"<unable to read intervention lines: {e}>"]
return list(out)[-max_lines:]
def _format_recent_event_lines(max_events: int) -> list: def _format_recent_event_lines(max_events: int) -> list:
with _memory_lock: with _memory_lock:
entries = list(recent_log)[-max_events:] entries = list(recent_log)[-max_events:]
@@ -1088,10 +1121,12 @@ def process_bug_log(player: str, description: str, config):
event_count = int(config.get("bug_log_event_lines", 40)) event_count = int(config.get("bug_log_event_lines", 40))
raw_count = int(config.get("bug_log_raw_lines", 120)) raw_count = int(config.get("bug_log_raw_lines", 120))
service_count = int(config.get("bug_log_service_lines", 30)) service_count = int(config.get("bug_log_service_lines", 30))
intervention_count = int(config.get("bug_log_intervention_lines", 80))
recent_events = _format_recent_event_lines(event_count) recent_events = _format_recent_event_lines(event_count)
raw_lines = _read_recent_raw_log_lines(config.get("log_path", ""), raw_count) raw_lines = _read_recent_raw_log_lines(config.get("log_path", ""), raw_count)
service_lines = _read_recent_service_action_lines(_service_log_path(config), service_count) service_lines = _read_recent_service_action_lines(_service_log_path(config), service_count)
intervention_lines = _read_recent_intervention_lines(_service_log_path(config), intervention_count)
bug_path = _bug_log_path(config) bug_path = _bug_log_path(config)
timestamp = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC') timestamp = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
@@ -1115,6 +1150,11 @@ def process_bug_log(player: str, description: str, config):
bf.write("\n".join(service_lines) + "\n") bf.write("\n".join(service_lines) + "\n")
else: else:
bf.write("(no AI service lines available)\n") bf.write("(no AI service lines available)\n")
bf.write("\n-- RECENT INTERVENTION CYCLES (SERVICE LOG) --\n")
if intervention_lines:
bf.write("\n".join(intervention_lines) + "\n")
else:
bf.write("(no intervention lines available)\n")
bf.write("\n-- RECENT RAW SERVER LOG LINES --\n") bf.write("\n-- RECENT RAW SERVER LOG LINES --\n")
if raw_lines: if raw_lines:
bf.write("\n".join(raw_lines) + "\n") bf.write("\n".join(raw_lines) + "\n")
@@ -1122,6 +1162,10 @@ def process_bug_log(player: str, description: str, config):
bf.write("(no raw lines available)\n") bf.write("(no raw lines available)\n")
log.info(f"BUG_LOG recorded by {player}: {desc} -> {bug_path}") log.info(f"BUG_LOG recorded by {player}: {desc} -> {bug_path}")
# Also write structured feedback to the training audit log
write_training_feedback(player, desc, config)
rcon( rcon(
f'tellraw {player} {{"text":"[BUG_LOG] Logged. Thank you.","color":"green"}}', f'tellraw {player} {{"text":"[BUG_LOG] Logged. Thank you.","color":"green"}}',
config["rcon_host"], config["rcon_port"], config["rcon_password"] config["rcon_host"], config["rcon_port"], config["rcon_password"]
@@ -1133,6 +1177,142 @@ def process_bug_log(player: str, description: str, config):
config["rcon_host"], config["rcon_port"], config["rcon_password"] config["rcon_host"], config["rcon_port"], config["rcon_password"]
) )
# ---------------------------------------------------------------------------
# Training Audit Log — structured JSONL for dataset expansion
# ---------------------------------------------------------------------------
_audit_lock = threading.Lock()
def _training_audit_path(config) -> str:
return config.get("training_audit_path", "/var/log/mc_training_audit.jsonl")
def write_training_audit(player: str, mode: str, user_message: str,
commands_generated: list, commands_executed: list,
message: str, context: dict, config: dict,
rcon_results: list = None):
"""
Write a structured training example to the audit JSONL.
Every pray/sudo interaction becomes a candidate training pair.
"""
audit_path = _training_audit_path(config)
server_ctx = {
"server_type": config.get("server_type", "paper"),
"version": "1.21.x",
"online_players": context.get("online_players", []),
}
# Add player position if available
try:
pos = get_player_xyz(player, config)
if pos:
server_ctx["player_position"] = {"x": pos[0], "y": pos[1], "z": pos[2]}
except Exception:
pass
admin_user = config.get("sudo_user", "slingshooter08")
entry = {
"timestamp": datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
"source": "live_playtest",
"category": _infer_category(mode, user_message, commands_executed),
"mode": mode,
"player": player,
"player_is_admin": player == admin_user,
"input": {
"user_message": user_message,
"server_context": server_ctx,
},
"output": {
"commands_generated": commands_generated or [],
"commands_executed": commands_executed or [],
"message": message or "",
},
"rcon_results": rcon_results or [],
"needs_review": True,
}
try:
parent = os.path.dirname(audit_path)
if parent:
os.makedirs(parent, exist_ok=True)
with _audit_lock:
with open(audit_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
except Exception as e:
log.error(f"Training audit write failed: {e}")
def write_training_feedback(player: str, description: str, config: dict):
"""
Write a bug_log feedback entry that links to the player's last interaction.
Feedback from non-admin players is tagged as unverified — they may have
wrong expectations about what should have happened.
"""
audit_path = _training_audit_path(config)
admin_user = config.get("sudo_user", "slingshooter08")
is_admin = player == admin_user
# Pull the last sudo/prayer context for this player
last_sudo = get_last_sudo_feedback(player)
last_prayer = None
with _memory_lock:
for mem in reversed(prayer_memory):
if mem.get("player") == player:
last_prayer = mem
break
entry = {
"timestamp": datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ'),
"source": "player_feedback",
"type": "bug_report",
"player": player,
"player_is_admin": is_admin,
"trust_level": "verified" if is_admin else "unverified",
"description": description,
"last_sudo_context": {
"prompt": last_sudo.get("prompt", ""),
"results": last_sudo.get("results", []),
"ineffective": last_sudo.get("ineffective", False),
} if last_sudo else None,
"last_prayer_context": {
"prayer": last_prayer.get("prayer", ""),
"god_message": last_prayer.get("god_message", ""),
} if last_prayer else None,
"needs_review": True,
"reviewer_notes": "" if is_admin else "UNVERIFIED: player may not understand expected behavior",
}
try:
parent = os.path.dirname(audit_path)
if parent:
os.makedirs(parent, exist_ok=True)
with _audit_lock:
with open(audit_path, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
except Exception as e:
log.error(f"Training feedback write failed: {e}")
def _infer_category(mode: str, user_message: str, commands_executed: list) -> str:
"""Infer dataset category from the interaction."""
low = (user_message or "").lower()
if mode == "god_system":
return "negative"
if not commands_executed:
# No commands = either info query, safety refusal, or empty response
q_words = ("what ", "how ", "why ", "explain ", "wiki ", "lookup ")
if any(low.startswith(w) for w in q_words) or low.endswith("?"):
return "info"
return "safety"
if mode == "god":
return "command_gen"
# sudo mode
troubleshoot_words = ("lag", "can't", "broken", "not working", "won't", "error", "crash", "stuck")
if any(w in low for w in troubleshoot_words):
return "troubleshoot"
return "command_gen"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# RCON # RCON
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -2271,10 +2451,12 @@ def fix_effect_command(cmd: str) -> str:
- effect <player> <effect> <duration> <amplifier> - effect <player> <effect> <duration> <amplifier>
-> effect give <player> <effect> <duration> <amplifier> -> effect give <player> <effect> <duration> <amplifier>
""" """
m = re.match(r'^effect\s+(\w+)\s+(minecraft:\w+)\s+(\d+)\s+(\d+)$', cmd) m = re.match(r'^effect\s+(\S+)\s+(minecraft:\w+)\s+(\d+)\s+(\d+)(?:\s+(true|false))?$', cmd)
if m: if m:
player, eff, dur, amp = m.groups() player, eff, dur, amp, hide = m.groups()
fixed = f"effect give {player} {eff} {dur} {amp}" fixed = f"effect give {player} {eff} {dur} {amp}"
if hide in ("true", "false"):
fixed += f" {hide}"
log.warning(f"Fixed malformed effect: '{cmd}' -> '{fixed}'") log.warning(f"Fixed malformed effect: '{cmd}' -> '{fixed}'")
return fixed return fixed
return cmd return cmd
@@ -2425,6 +2607,21 @@ def validate_command(cmd, online_players, fallback_player, config=None):
log.warning(f"Command blocked (unknown prefix for server_type={config.get('server_type', DEFAULT_SERVER_TYPE) if config else DEFAULT_SERVER_TYPE}): {resolved}") log.warning(f"Command blocked (unknown prefix for server_type={config.get('server_type', DEFAULT_SERVER_TYPE) if config else DEFAULT_SERVER_TYPE}): {resolved}")
return resolved, False return resolved, False
if resolved.startswith("tellraw "):
m = re.match(r'^tellraw\s+\S+\s+(.+)$', resolved)
if not m:
log.warning(f"Command blocked (malformed tellraw): {resolved}")
return resolved, False
payload = m.group(1).strip()
if not (payload.startswith("{") or payload.startswith("[")):
log.warning(f"Command blocked (tellraw payload not json): {resolved}")
return resolved, False
try:
json.loads(payload)
except Exception:
log.warning(f"Command blocked (invalid tellraw json): {resolved}")
return resolved, False
# Prevent execute-wrapper bypass (e.g. execute ... run gameMode s) # Prevent execute-wrapper bypass (e.g. execute ... run gameMode s)
if resolved.startswith("execute "): if resolved.startswith("execute "):
tail = resolved tail = resolved
@@ -2459,6 +2656,8 @@ def _is_destructive_intent(prompt: str) -> bool:
def _is_fire_intent(prompt: str) -> bool: def _is_fire_intent(prompt: str) -> bool:
p = (prompt or "").lower() p = (prompt or "").lower()
if "tnt" in p:
return False
return any(k in p for k in ("fire", "ignite", "burn", "flame")) return any(k in p for k in ("fire", "ignite", "burn", "flame"))
@@ -2734,6 +2933,11 @@ def execute_response(response, context, config, praying_player=None):
if not is_safe: if not is_safe:
continue continue
# Prevent unsolicited teleporting in unprompted interventions.
if praying_player is None and re.search(r'\btp\b', resolved):
log.warning(f"Blocked tp in unprompted intervention: {resolved}")
continue
safety_prefix = _tp_safety_prefix_commands(resolved, config) safety_prefix = _tp_safety_prefix_commands(resolved, config)
for scmd in safety_prefix: for scmd in safety_prefix:
sresolved, safe_ok = validate_command(scmd, context["online_players"], fallback, config) sresolved, safe_ok = validate_command(scmd, context["online_players"], fallback, config)
@@ -2892,7 +3096,8 @@ def process_sudo(player, prompt, config):
return return
sudo_user = config.get("sudo_user", "slingshooter08") sudo_user = config.get("sudo_user", "slingshooter08")
if player != sudo_user: allow_all = config.get("sudo_allow_all_players", False)
if player != sudo_user and not allow_all:
# Keep this private and quiet # Keep this private and quiet
rcon( rcon(
f'tellraw {player} {{"text":"[SUDO] Unauthorized.","color":"red"}}', f'tellraw {player} {{"text":"[SUDO] Unauthorized.","color":"red"}}',
@@ -2941,6 +3146,19 @@ def process_sudo(player, prompt, config):
# Deterministic lookup mode: information only, no command execution. # Deterministic lookup mode: information only, no command execution.
low = prompt.lower().strip() low = prompt.lower().strip()
# Deterministic status-check shortcut for common follow-up wording.
if re.search(r'\bdid that command do what i asked\b', low):
fb = get_last_sudo_feedback(player)
if not fb:
_send_private(player, "[SUDO-LOOKUP] No recent sudo execution context to evaluate.", config, "yellow")
return
if bool(fb.get("ineffective", False)):
_send_private(player, "[SUDO-LOOKUP] Likely no — recent execution looked ineffective.", config, "yellow")
else:
_send_private(player, "[SUDO-LOOKUP] Likely yes — recent execution looked successful.", config, "green")
return
lookup_prefixes = ("lookup ", "search ", "wiki ", "explain ", "what is ", "how do i ", "how to ") lookup_prefixes = ("lookup ", "search ", "wiki ", "explain ", "what is ", "how do i ", "how to ")
if low.startswith(lookup_prefixes) or _looks_like_lookup_question(prompt): if low.startswith(lookup_prefixes) or _looks_like_lookup_question(prompt):
query = prompt.strip() query = prompt.strip()
@@ -3130,14 +3348,15 @@ def process_sudo(player, prompt, config):
text=prompt, text=prompt,
context_payload={ context_payload={
"request": prompt, "request": prompt,
"player": player,
"online_players": online, "online_players": online,
"sudo_history": get_sudo_history_block(), "sudo_history": get_sudo_history_block(),
"sudo_failures": get_sudo_failures_block(player), "sudo_failures": get_sudo_failures_block(player),
"mode": "sudo", "mode": "sudo",
}, },
config=config, config=config,
allow_tools=bool(config.get("gateway_allow_tools_sudo", False)), allow_tools=bool(config.get("gateway_allow_tools_sudo", True)),
max_tool_steps=int(config.get("gateway_max_tool_steps", 2)), max_tool_steps=int(config.get("gateway_max_tool_steps", 4)),
) )
log.info(f"Gateway sudo tool_trace={out.get('tool_trace', [])}") log.info(f"Gateway sudo tool_trace={out.get('tool_trace', [])}")
commands = out.get("commands") or [] commands = out.get("commands") or []
@@ -3231,8 +3450,10 @@ def process_sudo(player, prompt, config):
if not is_safe: if not is_safe:
continue continue
log.info(f"SUDO execute: {resolved}") log.info(f"SUDO execute: {resolved}")
_sudo_trace(player, f"[SUDO TRY] {resolved}", config)
result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"])
log.info(f"SUDO result: {result!r}") log.info(f"SUDO result: {result!r}")
_sudo_trace(player, f"[SUDO RES] {str(result or '')[:180]}", config)
executed.append(resolved) executed.append(resolved)
results_seen.append((resolved, str(result or ""))) results_seen.append((resolved, str(result or "")))
time.sleep(0.2) time.sleep(0.2)
@@ -3251,8 +3472,10 @@ def process_sudo(player, prompt, config):
if not is_safe: if not is_safe:
continue continue
log.info(f"SUDO retry execute: {resolved}") log.info(f"SUDO retry execute: {resolved}")
_sudo_trace(player, f"[SUDO RETRY] {resolved}", config, color="yellow")
result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"])
log.info(f"SUDO retry result: {result!r}") log.info(f"SUDO retry result: {result!r}")
_sudo_trace(player, f"[SUDO RETRY RES] {str(result or '')[:180]}", config, color="yellow")
executed.append(resolved) executed.append(resolved)
results_seen.append((resolved, str(result or ""))) results_seen.append((resolved, str(result or "")))
time.sleep(0.12) time.sleep(0.12)
@@ -3270,8 +3493,10 @@ def process_sudo(player, prompt, config):
if not is_safe: if not is_safe:
continue continue
log.info(f"SUDO fallback execute: {resolved}") log.info(f"SUDO fallback execute: {resolved}")
_sudo_trace(player, f"[SUDO FALLBACK] {resolved}", config, color="red")
result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"])
log.info(f"SUDO fallback result: {result!r}") log.info(f"SUDO fallback result: {result!r}")
_sudo_trace(player, f"[SUDO FALLBACK RES] {str(result or '')[:180]}", config, color="red")
executed.append(resolved) executed.append(resolved)
results_seen.append((resolved, str(result or ""))) results_seen.append((resolved, str(result or "")))
time.sleep(0.15) time.sleep(0.15)
@@ -3287,8 +3512,10 @@ def process_sudo(player, prompt, config):
if not is_safe: if not is_safe:
continue continue
log.info(f"SUDO fire fallback execute: {resolved}") log.info(f"SUDO fire fallback execute: {resolved}")
_sudo_trace(player, f"[SUDO FIRE FALLBACK] {resolved}", config, color="gold")
result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"]) result = rcon(resolved, config["rcon_host"], config["rcon_port"], config["rcon_password"])
log.info(f"SUDO fire fallback result: {result!r}") log.info(f"SUDO fire fallback result: {result!r}")
_sudo_trace(player, f"[SUDO FIRE RES] {str(result or '')[:180]}", config, color="gold")
executed.append(resolved) executed.append(resolved)
results_seen.append((resolved, str(result or ""))) results_seen.append((resolved, str(result or "")))
time.sleep(0.15) time.sleep(0.15)
@@ -3304,6 +3531,19 @@ def process_sudo(player, prompt, config):
add_sudo_history(player, prompt, commands, executed) add_sudo_history(player, prompt, commands, executed)
# Training audit: log the full sudo interaction
write_training_audit(
player=player,
mode="sudo",
user_message=f"sudo {prompt}",
commands_generated=commands,
commands_executed=executed,
message="",
context={"online_players": online},
config=config,
rcon_results=[(cmd, res) for cmd, res in results_seen],
)
def get_player_xyz(player: str, config): def get_player_xyz(player: str, config):
"""Return integer xyz for a player using RCON entity data.""" """Return integer xyz for a player using RCON entity data."""
@@ -3511,6 +3751,18 @@ def process_prayer(player, prayer, config, cooldowns):
if god_msg: if god_msg:
add_prayer_memory(player, prayer, god_msg, config) add_prayer_memory(player, prayer, god_msg, config)
# Training audit: log the full interaction as a candidate training pair
write_training_audit(
player=player,
mode="god",
user_message=f"pray {prayer}",
commands_generated=response.get("commands") or [],
commands_executed=response.get("commands") or [], # prayer path doesn't track executed separately
message=god_msg,
context=context,
config=config,
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Divine intervention timer # Divine intervention timer
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------