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:
+258
-6
@@ -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:
|
||||
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:
|
||||
lines = deque(maxlen=max_lines * 8)
|
||||
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:
|
||||
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:]
|
||||
|
||||
|
||||
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:
|
||||
with _memory_lock:
|
||||
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))
|
||||
raw_count = int(config.get("bug_log_raw_lines", 120))
|
||||
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)
|
||||
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)
|
||||
intervention_lines = _read_recent_intervention_lines(_service_log_path(config), intervention_count)
|
||||
bug_path = _bug_log_path(config)
|
||||
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")
|
||||
else:
|
||||
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")
|
||||
if raw_lines:
|
||||
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")
|
||||
|
||||
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(
|
||||
f'tellraw {player} {{"text":"[BUG_LOG] Logged. Thank you.","color":"green"}}',
|
||||
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"]
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2271,10 +2451,12 @@ def fix_effect_command(cmd: str) -> str:
|
||||
- effect <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:
|
||||
player, eff, dur, amp = m.groups()
|
||||
player, eff, dur, amp, hide = m.groups()
|
||||
fixed = f"effect give {player} {eff} {dur} {amp}"
|
||||
if hide in ("true", "false"):
|
||||
fixed += f" {hide}"
|
||||
log.warning(f"Fixed malformed effect: '{cmd}' -> '{fixed}'")
|
||||
return fixed
|
||||
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}")
|
||||
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)
|
||||
if resolved.startswith("execute "):
|
||||
tail = resolved
|
||||
@@ -2459,6 +2656,8 @@ def _is_destructive_intent(prompt: str) -> bool:
|
||||
|
||||
def _is_fire_intent(prompt: str) -> bool:
|
||||
p = (prompt or "").lower()
|
||||
if "tnt" in p:
|
||||
return False
|
||||
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:
|
||||
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)
|
||||
for scmd in safety_prefix:
|
||||
sresolved, safe_ok = validate_command(scmd, context["online_players"], fallback, config)
|
||||
@@ -2892,7 +3096,8 @@ def process_sudo(player, prompt, config):
|
||||
return
|
||||
|
||||
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
|
||||
rcon(
|
||||
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.
|
||||
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 ")
|
||||
if low.startswith(lookup_prefixes) or _looks_like_lookup_question(prompt):
|
||||
query = prompt.strip()
|
||||
@@ -3130,14 +3348,15 @@ def process_sudo(player, prompt, config):
|
||||
text=prompt,
|
||||
context_payload={
|
||||
"request": prompt,
|
||||
"player": player,
|
||||
"online_players": online,
|
||||
"sudo_history": get_sudo_history_block(),
|
||||
"sudo_failures": get_sudo_failures_block(player),
|
||||
"mode": "sudo",
|
||||
},
|
||||
config=config,
|
||||
allow_tools=bool(config.get("gateway_allow_tools_sudo", False)),
|
||||
max_tool_steps=int(config.get("gateway_max_tool_steps", 2)),
|
||||
allow_tools=bool(config.get("gateway_allow_tools_sudo", True)),
|
||||
max_tool_steps=int(config.get("gateway_max_tool_steps", 4)),
|
||||
)
|
||||
log.info(f"Gateway sudo tool_trace={out.get('tool_trace', [])}")
|
||||
commands = out.get("commands") or []
|
||||
@@ -3231,8 +3450,10 @@ def process_sudo(player, prompt, config):
|
||||
if not is_safe:
|
||||
continue
|
||||
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"])
|
||||
log.info(f"SUDO result: {result!r}")
|
||||
_sudo_trace(player, f"[SUDO RES] {str(result or '')[:180]}", config)
|
||||
executed.append(resolved)
|
||||
results_seen.append((resolved, str(result or "")))
|
||||
time.sleep(0.2)
|
||||
@@ -3251,8 +3472,10 @@ def process_sudo(player, prompt, config):
|
||||
if not is_safe:
|
||||
continue
|
||||
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"])
|
||||
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)
|
||||
results_seen.append((resolved, str(result or "")))
|
||||
time.sleep(0.12)
|
||||
@@ -3270,8 +3493,10 @@ def process_sudo(player, prompt, config):
|
||||
if not is_safe:
|
||||
continue
|
||||
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"])
|
||||
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)
|
||||
results_seen.append((resolved, str(result or "")))
|
||||
time.sleep(0.15)
|
||||
@@ -3287,8 +3512,10 @@ def process_sudo(player, prompt, config):
|
||||
if not is_safe:
|
||||
continue
|
||||
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"])
|
||||
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)
|
||||
results_seen.append((resolved, str(result or "")))
|
||||
time.sleep(0.15)
|
||||
@@ -3304,6 +3531,19 @@ def process_sudo(player, prompt, config):
|
||||
|
||||
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):
|
||||
"""Return integer xyz for a player using RCON entity data."""
|
||||
@@ -3511,6 +3751,18 @@ def process_prayer(player, prayer, config, cooldowns):
|
||||
if god_msg:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user