splinter-keep/tools/engine.py

957 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
engine.py — The Chaos Game Engine
Owns the LLM interaction, prompt assembly, response parsing, and game state
persistence. The TUI (run.py) calls this module — they do not depend on each
other, only on the shared session/ file layout.
Split into sub-modules: paths, models, prompts, state, tools_handler, llm.
"""
from __future__ import annotations
import json
import re
import sys
from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import Iterator, Optional
from paths import (
CHAR_PATH, WORLD_PATH, BOOK_PATH, CONFIG_PATH, LOG_DIR,
)
from models import GenerationResult, TurnResult
from prompts import SYSTEM_PROMPT, PROSE_PROMPT
import state # read_file, read_recent_log, read_recent_book, truncate_world, append_llm_log
from tools_handler import (
execute_tool, describe_tool_action, describe_change,
parse_changes_block, extract_tool_calls,
)
from llm import set_llm_env, call_llm
# ── Game Engine ────────────────────────────────────────────────────────────
class GameEngine:
"""Owns the LLM interaction and game state persistence."""
def __init__(self, session_dir: str | Path | None = None):
from paths import SESSION_DIR
self.session_dir = Path(session_dir) if session_dir else SESSION_DIR
self.config: dict = {}
self._load_config()
# ── Config ──────────────────────────────────────────────────────────
def _load_config(self) -> None:
if not CONFIG_PATH.exists():
print(
"No session/config.json found. Creating default.\n"
"Edit the model field (e.g. 'ollama/llama3.1', 'openai/gpt-4', "
"'anthropic/claude-sonnet-4-20250514') and set api_key if needed.",
file=sys.stderr,
)
self.config = {
"llm": {
"model": "ollama/llama3.1",
"api_key": None,
"api_base": None,
"temperature": 0.8,
"max_tokens": 300,
}
}
self._save_config()
else:
raw = CONFIG_PATH.read_text()
self.config = json.loads(raw)
llm = self.config.get("llm", {})
if not llm.get("api_key"):
llm["api_key"] = None
def _save_config(self) -> None:
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
CONFIG_PATH.write_text(json.dumps(self.config, indent=2) + "\n")
@property
def model(self) -> str:
return self.config.get("llm", {}).get("model", "ollama/llama3.1")
@property
def api_key(self) -> str | None:
return self.config.get("llm", {}).get("api_key")
@property
def api_base(self) -> str | None:
return self.config.get("llm", {}).get("api_base")
@property
def temperature(self) -> float:
return self.config.get("llm", {}).get("temperature", 0.8)
@property
def max_tokens(self) -> int:
return self.config.get("llm", {}).get("max_tokens", 512)
@property
def timeout(self) -> int:
return self.config.get("llm", {}).get("timeout", 120)
# ── Context Assembly ────────────────────────────────────────────────
def build_system_prompt(self) -> str:
"""Assemble the system prompt with current game state."""
char = state.read_file(CHAR_PATH) or "*No character sheet.*"
world = state.truncate_world(state.read_file(WORLD_PATH) or "") or "*No world state.*"
log = state.read_recent_log()
story = state.read_recent_book()
return SYSTEM_PROMPT.substitute(
character=char, world=world, log=log, story=story
)
def build_user_message(
self,
player_action: str | None = None,
last_prompt: str | None = None,
**kwargs: str | None,
) -> str:
"""Build the user message for this turn's LLM call."""
if kwargs:
raise TypeError(
f"build_user_message() got unexpected keyword arguments: "
f"{set(kwargs)}. Did you mean 'last_prompt' instead of one of these?"
)
parts = []
if last_prompt:
parts.append(f"## Situation\n{last_prompt}")
if player_action:
parts.append(f"## Player's Request\n{player_action}")
has_existing_story = bool(
state.read_file(BOOK_PATH).strip()
) if not last_prompt else True
if not player_action and not last_prompt:
if has_existing_story:
raise RuntimeError(f"User action is required for every turn.")
else:
parts.append(
"## Instructions\n"
"This is a new story. Welcome the player and guide them through the game setup."
)
else:
parts.append(
"## Instructions\n"
"Advance the story based on the player's request. "
"All state is shown above — write the outcome directly."
)
return "\n\n".join(parts)
# ── LLM Call ────────────────────────────────────────────────────────
def generate(
self,
player_action: str | None = None,
last_narrative: str | None = None,
) -> GenerationResult:
"""
Synchronous generation. Calls the LLM, parses the response,
and returns a GenerationResult.
"""
system = self.build_system_prompt()
user = self.build_user_message(
player_action=player_action, last_prompt=last_narrative
)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
try:
import litellm
except ImportError:
return GenerationResult(
narrative="",
error="litellm is not installed. Run: pip install litellm",
)
set_llm_env(self.model, self.api_key, self.api_base)
try:
response = litellm.completion(
model=self.model,
messages=messages,
temperature=self.temperature,
stream=False,
timeout=self.timeout,
)
text = response.choices[0].message.content or ""
except Exception as e:
return GenerationResult(
narrative="",
error=f"LLM call failed: {e}",
)
return self.parse_response(text)
def generate_stream(
self,
player_action: str | None = None,
last_narrative: str | None = None,
) -> Iterator[str]:
"""
Streaming generator. Yields text chunks as they arrive from the LLM.
On completion, the final yield is the FULL text (for parsing).
"""
system = self.build_system_prompt()
user = self.build_user_message(
player_action=player_action, last_prompt=last_narrative
)
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
try:
import litellm
except ImportError:
yield json.dumps({
"error": "litellm is not installed. Run: pip install litellm"
})
return
set_llm_env(self.model, self.api_key, self.api_base)
try:
response = litellm.completion(
model=self.model,
messages=messages,
temperature=self.temperature,
stream=True,
timeout=self.timeout,
)
full_text = ""
for chunk in response:
delta = chunk.choices[0].delta.content or ""
if delta:
full_text += delta
yield full_text
yield full_text
except Exception as e:
yield json.dumps({"error": f"LLM call failed: {e}"})
# ──── Three-phase generation ────────────────────────────────────────
def generate_with_tools(
self,
player_action: str | None = None,
last_prompt: str | None = None,
on_thought: callable = None,
on_action: callable = None,
on_player_roll: callable = None,
on_debug: callable = None,
) -> TurnResult:
"""
Three-phase generation:
1. **Prose** — LLM writes the full book_log from context + player action.
2. **Summarize** — LLM condenses the book_log into one log line.
3. **Extract** — LLM reads the book_log and outputs tool calls for state changes.
"""
set_llm_env(self.model, self.api_key, self.api_base)
import random
datetime_now = datetime.now()
state.append_llm_log(f"\n{'='*60}")
state.append_llm_log(f"=== Turn — {datetime_now.strftime('%Y-%m-%d %H:%M:%S')} ===")
state.append_llm_log(f"{'='*60}")
if player_action:
state.append_llm_log(f"Player: {player_action}")
elif last_prompt:
state.append_llm_log(f"Resume from: {last_prompt[:120]}")
die_roll = random.randint(1, 6)
state.append_llm_log(f"Dice: {die_roll} (1d6)")
book_log = None
changes_block = ""
log_entry = None
user_prompt = self._auto_prompt("")
ambience = None
debug_info = ""
changes = []
for outer_attempt in range(3):
# ── Phase 1: Prose ────────────────────────────────────────────
if on_action:
on_action(f"Phase 1/3: writing story (dice={die_roll})")
if on_debug:
on_debug("phase", {"phase": 1, "name": "prose", "status": "start", "dice": die_roll, "outer_attempt": outer_attempt + 1})
system = PROSE_PROMPT.substitute(
character=state.read_file(CHAR_PATH) or "*No character sheet.*",
world=state.truncate_world(state.read_file(WORLD_PATH) or "") or "*No world state.*",
log=state.read_recent_log(),
story=state.read_recent_book(),
)
user = self.build_user_message(
player_action=player_action,
last_prompt=last_prompt,
)
user += f"\n\n*A die is cast: **{die_roll}** (1d6).*"
text = call_llm([
{"role": "system", "content": system},
{"role": "user", "content": user},
], model=self.model, temperature=self.temperature, timeout=self.timeout,
max_tokens=1024, label=f"Prose attempt {outer_attempt + 1}", on_debug=on_debug)
if not text or not text.strip():
if on_debug:
on_debug("phase", {"phase": 1, "status": "empty", "attempt": outer_attempt + 1})
continue
raw = text.strip()
changes_block = ""
if "### Changes" in raw:
parts = raw.split("### Changes", 1)
book_log = parts[0].strip()
changes_block = "### Changes" + parts[1]
else:
book_log = raw
if on_debug:
preview = book_log[:150].replace("\n", "\\n")
on_debug("phase", {"phase": 1, "status": "done", "chars": len(book_log), "changes": bool(changes_block), "preview": preview})
# ── Validation ────────────────────────────────────────────────
if on_debug:
on_debug("phase", {"phase": 1, "name": "validation", "status": "start"})
valid, reason = self._validate_narrative(book_log, on_debug=on_debug)
if not valid:
if on_debug:
on_debug("phase", {"phase": 1, "status": "validation_failed", "reason": reason, "outer_attempt": outer_attempt + 1})
book_log = None
continue
# ── Phase 2: Summarize ────────────────────────────────────────
if on_action:
on_action("Phase 2/3: summarizing story")
if on_debug:
on_debug("phase", {"phase": 2, "name": "summarize", "status": "start"})
log_context = state.read_recent_log()
log_entry = None
for p2_attempt in range(2):
context = book_log
if changes_block:
context += f"\n\n{changes_block}"
text = call_llm([
{"role": "user", "content":
f"Given the session log so far, summarize the new story in one line. "
f"Focus on who was involved (character and NPC names):\n\n"
f"## Session Log\n{log_context}\n\n"
f"## New Story\n{context}"}
], model=self.model, temperature=self.temperature, timeout=self.timeout,
max_tokens=self.max_tokens, label=f"Summarize attempt {p2_attempt + 1}", on_debug=on_debug)
if text and text.strip():
log_entry = text.strip().split("\n")[0][:300]
if on_debug:
on_debug("phase", {"phase": 2, "status": "done", "summary": log_entry})
break
if not log_entry:
log_entry = book_log.split("\n")[0][:120]
if on_debug:
on_debug("phase", {"phase": 2, "status": "fallback", "summary": log_entry})
# ── Phase 3: Extract state changes ────────────────────────────
if on_action:
on_action("Phase 3/3: extracting state changes")
if on_debug:
on_debug("phase", {"phase": 3, "name": "extract", "status": "start"})
user_prompt = self._auto_prompt(book_log)
ambience = None
phase3_errors = []
changes = []
# Step 1: Parse ### Changes block directly
if changes_block.strip():
for tc in parse_changes_block(changes_block):
name = tc["tool"]
args = tc.get("args", {})
if name == "finalize_turn":
continue
result = execute_tool(name, args)
if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"):
phase3_errors.append(f"{name}: {result}")
else:
desc = describe_change(name, args)
if desc:
changes.append(desc)
# Step 2: LLM Phase 3 for finalize_turn + any extra changes
previous_attempt = None
phase3_ok = False
for p3_attempt in range(5):
current_char = state.read_file(CHAR_PATH) or "*No character.*"
current_world = state.truncate_world(state.read_file(WORLD_PATH) or "") or "*No world.*"
phase3_prompt = (
f"## Current Character\n{current_char}\n\n"
f"## Current World\n{current_world}\n\n"
f"## Story\n{book_log}\n\n"
)
if changes_block.strip():
phase3_prompt += (
f"## Changes already applied\n{changes_block}\n\n"
f"Output the finalize_turn tool to end the turn. "
f"Add extra tool calls if you spot changes the list above missed.\n\n"
)
else:
phase3_prompt += (
f"Read the story and compare with current state. Output tool calls for any changes:\n\n"
)
phase3_prompt += (
f"Output ```tool blocks for changes only. Examples:\n\n"
)
if previous_attempt:
phase3_prompt += (
f"--- PREVIOUS ATTEMPT (had errors) ---\n"
f"{previous_attempt['output']}\n\n"
f"--- FEEDBACK ---\n"
f"{previous_attempt['feedback']}\n\n"
f"Fix the issues above. Output corrected tool calls only.\n\n"
)
text = call_llm([
{"role": "user", "content": phase3_prompt +
f"```tool\n{{\"tool\": \"modify_vitals\", \"args\": {{\"current_hp\": 5, \"cash\": 45}}}}\n```\n"
f"```tool\n{{\"tool\": \"modify_traits\", \"args\": {{\"dex\": 15}}}}\n```\n"
f"```tool\n{{\"tool\": \"add_to_inventory\", \"args\": {{\"item\": \"Silver key\"}}}}\n```\n"
f"```tool\n{{\"tool\": \"remove_from_inventory\", \"args\": {{\"item\": \"Torches (10)\"}}}}\n```\n"
f"```tool\n{{\"tool\": \"replace_gear\", \"args\": {{\"before\": \"Mace (1d6+1)\", \"after\": \"Mace (1d6+2, sharpened)\"}}}}\n```\n"
f"```tool\n{{\"tool\": \"add_note\", \"args\": {{\"note\": \"Found a hidden passage under the temple\"}}}}\n```\n"
f"```tool\n{{\"tool\": \"replace_note\", \"args\": {{\"before\": \"Old note text\", \"after\": \"New note text\"}}}}\n```\n"
f"```tool\n{{\"tool\": \"world_update\", \"args\": {{\"content\": \"# The World\\n\\n...full new world state...\"}}}}\n```\n"
f"```tool\n{{\"tool\": \"journal_update\", \"args\": {{\"add\": [\"Investigate the mine\"], \"done\": [\"Defeat the demon\"]}}}}\n```\n"
f"```tool\n{{\"tool\": \"finalize_turn\", \"args\": {{\"user_prompt\": \"What do you do?\", \"ambience\": \"dungeon\"}}}}\n```\n\n"
f"Only output tools for things that actually changed. Omit unchanged fields."}
], model=self.model, temperature=self.temperature, timeout=self.timeout,
max_tokens=self.max_tokens, label=f"Extract attempt {p3_attempt + 1}", on_debug=on_debug)
if not text or not text.strip():
if on_debug:
on_debug("phase", {"phase": 3, "status": "empty", "attempt": p3_attempt + 1})
continue
tool_calls_list = extract_tool_calls(
text, round_num=p3_attempt + 1, on_debug=on_debug
)
if on_debug and tool_calls_list:
names = [tc.get("tool", "?") for tc in tool_calls_list if tc.get("tool") != "finalize_turn"]
fin = any(tc.get("tool") == "finalize_turn" for tc in tool_calls_list)
on_debug("phase", {"phase": 3, "status": "tools_found", "tools": names, "has_finalize": fin})
errors = []
attempt_changes = []
for tc in tool_calls_list:
name = tc.get("tool", "?")
args = tc.get("args", {})
if name == "finalize_turn":
if args.get("user_prompt"):
user_prompt = args["user_prompt"]
if args.get("ambience"):
ambience = args["ambience"]
continue
if on_action:
on_action(f"State: {describe_tool_action(name, args)}")
if on_debug:
on_debug("tool_call", {"round": p3_attempt + 1, "tool": name, "args": args})
if name == "player_roll" and on_player_roll:
dice = args.get("dice", "1d6")
reason = args.get("reason", "a check")
roll_val = on_player_roll(dice, reason)
result = f"Player rolled {dice} for '{reason}': {roll_val}"
else:
result = execute_tool(name, args)
if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"):
errors.append(f"{name}: {result}")
else:
desc = describe_change(name, args)
if desc:
attempt_changes.append(desc)
if on_debug:
on_debug("tool_result", {"round": p3_attempt + 1, "tool": name, "result": result})
if not errors:
phase3_ok = True
debug_info = ""
changes.extend(attempt_changes)
if on_debug:
on_debug("phase", {"phase": 3, "status": "done", "applied": len([tc for tc in tool_calls_list if tc.get("tool") != "finalize_turn"])})
break
phase3_errors = errors
debug_info = "; ".join(errors)
if on_debug:
on_debug("phase", {"phase": 3, "status": "errors", "errors": errors, "attempt": p3_attempt + 1})
feedback_lines = ["The previous tool calls had errors:"]
for e in errors:
feedback_lines.append(f"- {e}")
feedback_lines.append("")
feedback_lines.append("Fix ALL issues above. Use correct tool names, valid JSON, and reasonable values.")
previous_attempt = {"output": text, "feedback": "\n".join(feedback_lines)}
if phase3_ok:
break
if on_debug:
on_debug("phase", {"phase": 3, "status": "exhausted", "errors": phase3_errors})
on_debug("phase", {"phase": 1, "status": "retry_after_phase3_failure", "outer_attempt": outer_attempt + 1})
book_log = None
if not book_log:
return TurnResult(error="Generation failed after exhausting all retries")
# ── Finalize ──────────────────────────────────────────────────────
if on_action:
on_action("Turn complete")
if on_debug:
on_debug("phase_done", {
"book_log_chars": len(book_log),
"log_entry": log_entry,
"user_prompt": user_prompt,
"ambience": ambience,
"extract_errors": debug_info or None,
})
state.append_llm_log(
f"\n--- FINAL ---\n"
f"book_log: {book_log[:200]}\n"
f"log_entry: {log_entry}\n"
f"user_prompt: {user_prompt}\n"
f"ambience: {ambience}\n"
)
return TurnResult(
book_log=book_log,
log_entry=log_entry,
user_prompt=user_prompt,
ambience=ambience,
debug_info=debug_info,
changes=changes,
)
# ──── Single-call generation ─────────────────────────────────────────
def generate_with_tools_single(
self,
player_action: str | None = None,
last_prompt: str | None = None,
on_thought: callable = None,
on_action: callable = None,
on_player_roll: callable = None,
on_debug: callable = None,
) -> TurnResult:
"""
Single-call generation using tools.
Uses a single LLM call with all tools available — LLM outputs
narrative + tool blocks in one go. No retry loop.
"""
datetime_now = datetime.now()
state.append_llm_log(f"\n{'='*60}")
state.append_llm_log(f"=== Turn — {datetime_now.strftime('%Y-%m-%d %H:%M:%S')} ===")
state.append_llm_log(f"{'='*60}")
if player_action:
state.append_llm_log(f"Player: {player_action}")
elif last_prompt:
state.append_llm_log(f"Resume from: {last_prompt[:120]}")
strategy_name = "tools"
if on_action:
on_action(f"LLM: {self.model} | temp={self.temperature} | tokens={self.max_tokens} | strategy={strategy_name}")
if on_debug:
on_debug("config", {"model": self.model, "temperature": self.temperature, "max_tokens": self.max_tokens, "strategy": strategy_name})
import random
die_roll = random.randint(1, 6)
state.append_llm_log(f"Dice: {die_roll} (1d6)")
system = """You are an RPG dungeon master. The player just took an action.
Output ONLY ```tool blocks — no prose, no reasoning, no explanation outside tool blocks. Every piece of output must be in a tool block.
Use these tools to perform every action. Wrap each in its own ```tool block:
```tool
{"tool": "narrative", "args": {"text": "The full vivid narrative prose goes here."}}
```
```tool
{"tool": "modify_vitals", "args": {"current_hp": 5, "cash": 45}}
```
```tool
{"tool": "modify_traits", "args": {"dex": 15}}
```
```tool
{"tool": "add_to_inventory", "args": {"item": "Silver key"}}
```
```tool
{"tool": "remove_from_inventory", "args": {"item": "Torches (10)"}}
```
```tool
{"tool": "replace_gear", "args": {"before": "Mace (1d6+1)", "after": "Mace (1d6+2, sharpened)"}}
```
```tool
{"tool": "add_note", "args": {"note": "Found a hidden passage under the temple"}}
```
```tool
{"tool": "replace_note", "args": {"before": "Old note text", "after": "New note text"}}
```
```tool
{"tool": "world_update", "args": {"content": "# The World\n\n...full new world state..."}}
```
```tool
{"tool": "journal_update", "args": {"add": ["Investigate the mine"], "done": ["Defeat the demon"]}}
```
```tool
{"tool": "finalize_turn", "args": {"user_prompt": "What do you do?", "ambience": "dungeon"}}
```
"""
system += PROSE_PROMPT.substitute(
character=state.read_file(CHAR_PATH) or "*No character sheet.*",
world=state.truncate_world(state.read_file(WORLD_PATH) or "") or "*No world state.*",
log=state.read_recent_log(),
story=state.read_recent_book(),
)
user = self.build_user_message(
player_action=player_action,
last_prompt=last_prompt,
)
user += f"\n\n*A die is cast: **{die_roll}** (1d6).*"
start_time = datetime.now()
set_llm_env(self.model, self.api_key, self.api_base)
state.append_llm_log(f"\n[TOOL] Single call — {len(system)} chars system, {len(user)} chars user")
state.append_llm_log(f"System preview: {system.split(chr(10))[0][:80]}...")
state.append_llm_log(f"User preview: {user.split(chr(10))[0][:80]}...")
text = call_llm(
[{"role": "system", "content": system},
{"role": "user", "content": user}],
model=self.model, temperature=self.temperature, timeout=self.timeout,
max_tokens=4096, label="Single tool call", on_debug=on_debug,
)
total_elapsed = (datetime.now() - start_time).total_seconds() * 1000
if text:
state.append_llm_log(f"\n[TOOL] got {len(text)} chars in {total_elapsed:.1f}ms")
if not text or not text.strip():
return TurnResult(error="Single tool call returned empty response")
raw = text.strip()
book_log = ""
log_entry = None
user_prompt = self._auto_prompt("")
ambience = None
tool_calls = []
changes = []
phase3_errors = []
tool_pattern = r"```tool\s*\n?(.*?)\n?```"
matches = re.findall(tool_pattern, text, re.DOTALL)
if matches:
for block in matches:
block = block.strip()
try:
tc = json.loads(block)
tool_calls.append(tc)
name = tc.get("tool", "unknown")
args = tc.get("args", {})
state.append_llm_log(f"\n[EXTRACT] {name}: {json.dumps(args)[:100]}")
if name == "narrative":
book_log = args.get("text", book_log)
elif name == "finalize_turn":
if args.get("user_prompt"):
user_prompt = args["user_prompt"]
if args.get("ambience"):
ambience = args["ambience"]
except json.JSONDecodeError as e:
state.append_llm_log(f"\n[EXTRACT] bad JSON: {e}")
continue
log_entry = None
if book_log:
clean = re.sub(r'\s+', ' ', book_log).strip()
first_sentence = re.split(r'(?<=[.!?])\s+', clean)
if first_sentence:
log_entry = first_sentence[0].strip()[:200]
else:
log_entry = clean[:200]
state.append_llm_log(f"\n[SUMMARY] \"{log_entry}\"")
extr_start = datetime.now()
for tc in tool_calls:
name = tc.get("tool", "unknown")
args = tc.get("args", {})
if name in ("finalize_turn", "narrative"):
continue
result = execute_tool(name, args)
if result.startswith("**Error:") or result.startswith("Tool error") or result.startswith("Unknown"):
phase3_errors.append(f"{name}: {result}")
else:
desc = describe_change(name, args)
if desc:
changes.append(desc)
apply_elapsed = (datetime.now() - extr_start).total_seconds() * 1000
state.append_llm_log(f"\n[APPLY] {len(changes)} changes in {apply_elapsed:.1f}ms")
else:
state.append_llm_log(f"\n[TOOL] no tool blocks found")
elapsed = (datetime.now() - start_time).total_seconds() * 1000
if on_action:
on_action("Turn complete")
if on_debug:
applied = len([tc for tc in tool_calls if tc.get("tool") != "finalize_turn"])
on_debug("phase_done", {
"book_log_chars": len(book_log),
"log_entry": log_entry,
"user_prompt": user_prompt,
"ambience": ambience,
"extract_errors": phase3_errors or None,
"total_elapsed_ms": elapsed,
"tool_calls_count": len(tool_calls),
"applied_changes_count": applied,
"tool_call_results": tool_calls,
})
self._log_turn_details(
player_action=player_action or last_prompt or "",
last_prompt=last_prompt or "",
strategy_name=strategy_name,
die_roll=die_roll,
model=self.model,
temperature=self.temperature,
max_tokens=self.max_tokens,
book_log=book_log,
log_entry=log_entry or "",
ambience=ambience,
tool_calls=tool_calls,
on_debug=on_debug,
)
return TurnResult(
book_log=book_log,
log_entry=log_entry,
user_prompt=user_prompt,
ambience=ambience,
debug_info="; ".join(phase3_errors) if phase3_errors else "",
changes=changes,
)
# ── Helpers ─────────────────────────────────────────────────────────
@staticmethod
def _auto_prompt(book_log: str) -> str:
"""Fallback player prompt."""
return "**What do you do?**"
def _validate_narrative(self, book_log: str, *, on_debug: callable = None) -> tuple[bool, str]:
"""Check if book_log is acceptable narrative. Returns (ok, reason)."""
lines = book_log.strip().split("\n")
if not lines:
return False, "Empty narrative"
common = Counter(lines).most_common(1)
if common and common[0][1] >= 5:
return False, f"Repetition: '{common[0][0][:60]}' ×{common[0][1]}"
mech_lines = [l for l in lines if re.match(
r'^\*\*(?:Roll|Damage|Success|Failure|Check|Save|Hit|Miss|'
r'Strenght|Dexterity|Willpower|STR|DEX|WIL|'
r'(?:[A-Z][a-z]+(?: \(\w+\))?:))',
l
)]
if mech_lines:
ratio = len(mech_lines) / len(lines)
if ratio > 0.3:
return False, f"Game mechanics dominate ({len(mech_lines)}/{len(lines)} lines)"
if re.search(r'```(?:tool|json)', book_log):
return False, "Contains unprocessed tool blocks"
prose = re.sub(r'[*_#>`~\-\d]', '', book_log).strip()
if len(prose) < 50:
return False, "Too short to be meaningful"
text = call_llm([
{"role": "user", "content":
f"Rate this RPG narrative quality 1-5.\n"
f"1 = unreadable (spam, repetition, pure mechanics, garbled)\n"
f"2 = poor (mostly mechanics, little story)\n"
f"3 = acceptable (some narrative but rough)\n"
f"4 = good (solid prose, minor issues)\n"
f"5 = excellent (vivid, engaging)\n"
f"Reply with ONLY a single digit 1-5.\n\n"
f"{book_log[:600]}"}
], model=self.model, temperature=self.temperature, timeout=self.timeout,
max_tokens=2, label="Narrative validation", on_debug=on_debug)
if text and text.strip().isdigit():
score = int(text.strip())
if score < 3:
return False, f"Quality score: {score}/5"
return True, ""
# ── Response Parsing ────────────────────────────────────────────────
@staticmethod
def parse_response(text: str) -> GenerationResult:
"""
Parse a full LLM response into a GenerationResult.
Extracts the JSON block and splits narrative from it.
"""
if text.startswith('{"error":'):
try:
err = json.loads(text).get("error", "Unknown error")
except json.JSONDecodeError:
err = "Unknown error"
return GenerationResult(narrative="", error=err)
json_pattern = r"```json\s*\n?(.*?)\n?```"
matches = re.findall(json_pattern, text, re.DOTALL)
narrative = text
data = {}
if matches:
json_str = matches[-1].strip()
narrative = text[: text.rfind("```json")]
narrative_lines = []
for line in narrative.splitlines():
if not line.lstrip().startswith('book_log:'):
narrative_lines.append(line)
narrative = "\n".join(narrative_lines).strip()
try:
data = json.loads(json_str)
except json.JSONDecodeError:
pass
else:
text_stripped = text.strip()
if text_stripped.startswith("{") and text_stripped.endswith("}"):
try:
data = json.loads(text_stripped)
narrative = data.get("narrative", "")
except json.JSONDecodeError:
pass
return GenerationResult(
narrative=narrative or text,
choices=data.get("choices", []),
log_entry=data.get("log_entry"),
ambience=data.get("ambience"),
character_updates=data.get("character_updates"),
world_updates=data.get("world_updates"),
journal_add=data.get("journal_add", []),
journal_done=data.get("journal_done", []),
)
# ── Logging ─────────────────────────────────────────────────────────
def _log_turn_details(
self,
player_action: str,
last_prompt: str,
strategy_name: str,
die_roll: int,
model: str,
temperature: float,
max_tokens: int,
book_log: str,
log_entry: str,
ambience: Optional[str],
tool_calls: list,
on_debug,
) -> None:
"""Write structured turn summary to llm.log and fire TUI debug event."""
ts = datetime.now().isoformat()
output_chars = len(book_log)
output_words = len(book_log.split()) if book_log else 0
applied = len([tc for tc in tool_calls if tc.get("tool") != "finalize_turn"])
state.append_llm_log("")
state.append_llm_log(f"┌─ Turn Details — {ts}")
state.append_llm_log(f"├─ Input: {player_action}")
state.append_llm_log(f"├─ Last Prompt: {last_prompt}")
state.append_llm_log(f"├─ Strategy: {strategy_name}")
state.append_llm_log(f"├─ Dice: {die_roll} (1d6)")
state.append_llm_log(f"├─ Model: {model} | Temp: {temperature} | Tokens: {max_tokens}")
state.append_llm_log(f"├─ Output: {output_chars} chars ({output_words} words)")
state.append_llm_log(f"├─ Log Entry: {log_entry}")
state.append_llm_log(f"├─ Ambience: {ambience or 'None'}")
tools_preview = ", ".join(tc.get("tool", "?") for tc in tool_calls)
state.append_llm_log(f"├─ Tool Calls: {len(tool_calls)} ({tools_preview})")
state.append_llm_log(
"└─────────────────────────────────────────────────────────────────────────────────────────┘"
)
if on_debug:
on_debug("turn_details", {
"timestamp": ts,
"model": model,
"temperature": temperature,
"max_tokens": max_tokens,
"strategy_name": strategy_name,
"die_roll": die_roll,
"player_action": player_action,
"book_log_chars": output_chars,
"book_log_words": output_words,
"ambience": ambience,
"tool_calls_count": len(tool_calls),
"applied_changes_count": applied,
"tool_call_results": tool_calls,
})
# ── CLI entry point (for testing) ─────────────────────────────────────────
def main():
"""Generate a turn from the command line (debug/testing)."""
import argparse
parser = argparse.ArgumentParser(description="The Chaos Game Engine (CLI)")
parser.add_argument("--action", "-a", help="Player action text")
parser.add_argument("--last", "-l", help="Last narrative text")
args = parser.parse_args()
engine = GameEngine()
result = engine.generate_with_tools_single(
player_action=args.action,
last_prompt=args.last,
)
if result.error:
print(f"ERROR: {result.error}", file=sys.stderr)
sys.exit(1)
print(result.book_log)
if result.user_prompt:
print(f"\n{result.user_prompt}")
if result.log_entry:
print(f"\n[Log] {result.log_entry}")
if result.ambience:
print(f"[Ambience] {result.ambience}")
if __name__ == "__main__":
main()