More refactors
This commit is contained in:
parent
e9a1187f34
commit
545d3bcac0
@ -1,15 +1,33 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
ERRORS=$(python3 -c "import os; [f for f in os.listdir('./tools') if f.endswith('.py') and os.path.getsize(os.path.join('./tools', f)) > 2048]")
|
THRESHOLD=30000 # bytes — flag files over ~30 KB
|
||||||
if [ -z "$ERRORS" ]; then
|
|
||||||
echo "Compiling tools/*.py..."
|
# Check all .py files in tools/ and tools/engine_lib/
|
||||||
if python3 -m compileall tools/*.py; then
|
OVERSIZED=$(python3 -c "
|
||||||
echo "OK"
|
import os
|
||||||
else
|
threshold = $THRESHOLD
|
||||||
echo "Compilation failed"
|
dirs = ['./tools', './tools/engine_lib']
|
||||||
exit 1
|
for d in dirs:
|
||||||
fi
|
if not os.path.isdir(d):
|
||||||
else
|
continue
|
||||||
echo "You need to refactor this:"
|
for f in sorted(os.listdir(d)):
|
||||||
echo "$ERRORS"
|
if not f.endswith('.py'):
|
||||||
|
continue
|
||||||
|
path = os.path.join(d, f)
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
if size > threshold:
|
||||||
|
print(f'{path} ({size} bytes)')
|
||||||
|
")
|
||||||
|
|
||||||
|
if [ -n "$OVERSIZED" ]; then
|
||||||
|
echo "Oversized files (>${THRESHOLD} bytes) — consider refactoring:"
|
||||||
|
echo "$OVERSIZED"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Compiling tools/*.py and tools/engine_lib/*.py..."
|
||||||
|
if python3 -m compileall tools/*.py tools/engine_lib/*.py; then
|
||||||
|
echo "OK"
|
||||||
|
else
|
||||||
|
echo "Compilation failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
892
tools/engine.py
892
tools/engine.py
@ -2,248 +2,85 @@
|
|||||||
"""
|
"""
|
||||||
engine.py — The Chaos Game Engine
|
engine.py — The Chaos Game Engine
|
||||||
|
|
||||||
Owns the LLM interaction, prompt assembly, response parsing, and game state
|
Thin coordinator that owns the GameEngine class. All heavy lifting is
|
||||||
persistence. The TUI (run.py) calls this module — they do not depend on each
|
delegated to sub-modules: paths, models, prompts, config, context,
|
||||||
other, only on the shared session/ file layout.
|
state, tools_handler, llm, validation, parsing, strategies.
|
||||||
|
|
||||||
Split into sub-modules: paths, models, prompts, state, tools_handler, llm.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import sys
|
import sys
|
||||||
from collections import Counter
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterator, Optional
|
|
||||||
|
|
||||||
from paths import (
|
from engine_lib.paths import CONFIG_PATH
|
||||||
CHAR_PATH, WORLD_PATH, BOOK_PATH, CONFIG_PATH, LOG_DIR,
|
from engine_lib.models import GenerationResult, TurnResult
|
||||||
)
|
from engine_lib import config
|
||||||
from models import GenerationResult, TurnResult
|
from engine_lib import strategies
|
||||||
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:
|
class GameEngine:
|
||||||
"""Owns the LLM interaction and game state persistence."""
|
"""Owns configuration and delegates generation to standalone strategies."""
|
||||||
|
|
||||||
def __init__(self, session_dir: str | Path | None = None):
|
def __init__(self, session_dir: str | Path | None = None):
|
||||||
from paths import SESSION_DIR
|
from engine_lib.paths import SESSION_DIR
|
||||||
self.session_dir = Path(session_dir) if session_dir else SESSION_DIR
|
self.session_dir = Path(session_dir) if session_dir else SESSION_DIR
|
||||||
self.config: dict = {}
|
self.config = config.load_config(CONFIG_PATH)
|
||||||
self._load_config()
|
|
||||||
|
|
||||||
# ── Config ──────────────────────────────────────────────────────────
|
# ── Config accessors ────────────────────────────────────────────────
|
||||||
|
|
||||||
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
|
@property
|
||||||
def model(self) -> str:
|
def model(self) -> str:
|
||||||
return self.config.get("llm", {}).get("model", "ollama/llama3.1")
|
return config.get_model(self.config)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def api_key(self) -> str | None:
|
def api_key(self) -> str | None:
|
||||||
return self.config.get("llm", {}).get("api_key")
|
return config.get_api_key(self.config)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def api_base(self) -> str | None:
|
def api_base(self) -> str | None:
|
||||||
return self.config.get("llm", {}).get("api_base")
|
return config.get_api_base(self.config)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def temperature(self) -> float:
|
def temperature(self) -> float:
|
||||||
return self.config.get("llm", {}).get("temperature", 0.8)
|
return config.get_temperature(self.config)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def max_tokens(self) -> int:
|
def max_tokens(self) -> int:
|
||||||
return self.config.get("llm", {}).get("max_tokens", 512)
|
return config.get_max_tokens(self.config)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def timeout(self) -> int:
|
def timeout(self) -> int:
|
||||||
return self.config.get("llm", {}).get("timeout", 120)
|
return config.get_timeout(self.config)
|
||||||
|
|
||||||
# ── Context Assembly ────────────────────────────────────────────────
|
# ── Generation (delegated) ──────────────────────────────────────────
|
||||||
|
|
||||||
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(
|
def generate(
|
||||||
self,
|
self,
|
||||||
player_action: str | None = None,
|
player_action: str | None = None,
|
||||||
last_narrative: str | None = None,
|
last_narrative: str | None = None,
|
||||||
) -> GenerationResult:
|
) -> GenerationResult:
|
||||||
"""
|
return strategies.generate(
|
||||||
Synchronous generation. Calls the LLM, parses the response,
|
player_action=player_action,
|
||||||
and returns a GenerationResult.
|
last_narrative=last_narrative,
|
||||||
"""
|
|
||||||
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,
|
model=self.model,
|
||||||
messages=messages,
|
|
||||||
temperature=self.temperature,
|
temperature=self.temperature,
|
||||||
stream=False,
|
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
)
|
max_tokens=self.max_tokens,
|
||||||
text = response.choices[0].message.content or ""
|
api_key=self.api_key,
|
||||||
except Exception as e:
|
api_base=self.api_base,
|
||||||
return GenerationResult(
|
|
||||||
narrative="",
|
|
||||||
error=f"LLM call failed: {e}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.parse_response(text)
|
def generate_stream(self, player_action=None, last_narrative=None):
|
||||||
|
yield from strategies.generate_stream(
|
||||||
def generate_stream(
|
player_action=player_action,
|
||||||
self,
|
last_narrative=last_narrative,
|
||||||
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,
|
model=self.model,
|
||||||
messages=messages,
|
|
||||||
temperature=self.temperature,
|
temperature=self.temperature,
|
||||||
stream=True,
|
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
|
max_tokens=self.max_tokens,
|
||||||
|
api_key=self.api_key,
|
||||||
|
api_base=self.api_base,
|
||||||
)
|
)
|
||||||
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(
|
def generate_with_tools(
|
||||||
self,
|
self,
|
||||||
@ -254,301 +91,20 @@ class GameEngine:
|
|||||||
on_player_roll: callable = None,
|
on_player_roll: callable = None,
|
||||||
on_debug: callable = None,
|
on_debug: callable = None,
|
||||||
) -> TurnResult:
|
) -> TurnResult:
|
||||||
"""
|
return strategies.generate_with_tools(
|
||||||
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,
|
player_action=player_action,
|
||||||
last_prompt=last_prompt,
|
last_prompt=last_prompt,
|
||||||
|
on_thought=on_thought,
|
||||||
|
on_action=on_action,
|
||||||
|
on_player_roll=on_player_roll,
|
||||||
|
on_debug=on_debug,
|
||||||
|
model=self.model,
|
||||||
|
temperature=self.temperature,
|
||||||
|
timeout=self.timeout,
|
||||||
|
max_tokens=self.max_tokens,
|
||||||
|
api_key=self.api_key,
|
||||||
|
api_base=self.api_base,
|
||||||
)
|
)
|
||||||
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(
|
def generate_with_tools_single(
|
||||||
self,
|
self,
|
||||||
@ -559,368 +115,20 @@ class GameEngine:
|
|||||||
on_player_roll: callable = None,
|
on_player_roll: callable = None,
|
||||||
on_debug: callable = None,
|
on_debug: callable = None,
|
||||||
) -> TurnResult:
|
) -> TurnResult:
|
||||||
"""
|
return strategies.generate_with_tools_single(
|
||||||
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,
|
player_action=player_action,
|
||||||
last_prompt=last_prompt,
|
last_prompt=last_prompt,
|
||||||
)
|
on_thought=on_thought,
|
||||||
user += f"\n\n*A die is cast: **{die_roll}** (1d6).*"
|
on_action=on_action,
|
||||||
|
on_player_roll=on_player_roll,
|
||||||
start_time = datetime.now()
|
on_debug=on_debug,
|
||||||
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,
|
model=self.model,
|
||||||
temperature=self.temperature,
|
temperature=self.temperature,
|
||||||
|
timeout=self.timeout,
|
||||||
max_tokens=self.max_tokens,
|
max_tokens=self.max_tokens,
|
||||||
book_log=book_log,
|
api_key=self.api_key,
|
||||||
log_entry=log_entry or "",
|
api_base=self.api_base,
|
||||||
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) ─────────────────────────────────────────
|
# ── CLI entry point (for testing) ─────────────────────────────────────────
|
||||||
|
|||||||
0
tools/engine_lib/__init__.py
Normal file
0
tools/engine_lib/__init__.py
Normal file
74
tools/engine_lib/config.py
Normal file
74
tools/engine_lib/config.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
config.py — LLM configuration loading and accessors for The Chaos engine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .paths import CONFIG_PATH
|
||||||
|
|
||||||
|
DEFAULT_CONFIG: dict = {
|
||||||
|
"llm": {
|
||||||
|
"model": "ollama/llama3.1",
|
||||||
|
"api_key": None,
|
||||||
|
"api_base": None,
|
||||||
|
"temperature": 0.8,
|
||||||
|
"max_tokens": 300,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: Path = CONFIG_PATH) -> dict:
|
||||||
|
"""Load config from path, creating default if missing. Returns config dict."""
|
||||||
|
if not 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,
|
||||||
|
)
|
||||||
|
cfg = dict(DEFAULT_CONFIG)
|
||||||
|
save_config(cfg, path)
|
||||||
|
return cfg
|
||||||
|
raw = path.read_text()
|
||||||
|
cfg = json.loads(raw)
|
||||||
|
llm = cfg.get("llm", {})
|
||||||
|
if not llm.get("api_key"):
|
||||||
|
llm["api_key"] = None
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(config: dict, path: Path = CONFIG_PATH) -> None:
|
||||||
|
"""Save config dict to path."""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(config, indent=2) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Accessors ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_model(config: dict) -> str:
|
||||||
|
return config.get("llm", {}).get("model", "ollama/llama3.1")
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_key(config: dict) -> str | None:
|
||||||
|
return config.get("llm", {}).get("api_key")
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_base(config: dict) -> str | None:
|
||||||
|
return config.get("llm", {}).get("api_base")
|
||||||
|
|
||||||
|
|
||||||
|
def get_temperature(config: dict) -> float:
|
||||||
|
return config.get("llm", {}).get("temperature", 0.8)
|
||||||
|
|
||||||
|
|
||||||
|
def get_max_tokens(config: dict) -> int:
|
||||||
|
return config.get("llm", {}).get("max_tokens", 512)
|
||||||
|
|
||||||
|
|
||||||
|
def get_timeout(config: dict) -> int:
|
||||||
|
return config.get("llm", {}).get("timeout", 120)
|
||||||
74
tools/engine_lib/context.py
Normal file
74
tools/engine_lib/context.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
context.py — System prompt and user message assembly for The Chaos engine.
|
||||||
|
|
||||||
|
All functions are standalone — no dependency on GameEngine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .paths import CHAR_PATH, WORLD_PATH, BOOK_PATH
|
||||||
|
from .prompts import SYSTEM_PROMPT
|
||||||
|
from . import state
|
||||||
|
|
||||||
|
|
||||||
|
def build_system_prompt() -> 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_prose_prompt() -> str:
|
||||||
|
"""Assemble the prose-generation prompt with current game state."""
|
||||||
|
from .prompts import PROSE_PROMPT
|
||||||
|
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 PROSE_PROMPT.substitute(
|
||||||
|
character=char, world=world, log=log, story=story
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_message(
|
||||||
|
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("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)
|
||||||
@ -10,7 +10,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from state import append_llm_log
|
from .state import append_llm_log
|
||||||
|
|
||||||
|
|
||||||
def set_llm_env(model: str, api_key: str | None, api_base: str | None) -> None:
|
def set_llm_env(model: str, api_key: str | None, api_base: str | None) -> None:
|
||||||
121
tools/engine_lib/parsing.py
Normal file
121
tools/engine_lib/parsing.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
parsing.py — LLM response parsing and turn logging for The Chaos engine.
|
||||||
|
|
||||||
|
Standalone functions — no dependency on GameEngine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .models import GenerationResult
|
||||||
|
from . import state
|
||||||
|
|
||||||
|
|
||||||
|
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", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def log_turn_details(
|
||||||
|
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,
|
||||||
|
) -> 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,
|
||||||
|
})
|
||||||
@ -1,8 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
config_paths.py — Path constants for The Chaos game engine.
|
paths.py — Path constants for The Chaos game engine.
|
||||||
|
|
||||||
Shared by engine.py, run.py, and all sub-modules.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -13,12 +13,12 @@ import sys
|
|||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from paths import (
|
from .paths import (
|
||||||
CHAR_PATH, WORLD_PATH, BOOK_PATH, JOURNAL_PATH, AMBIENCE_PATH,
|
CHAR_PATH, WORLD_PATH, BOOK_PATH, JOURNAL_PATH, AMBIENCE_PATH,
|
||||||
LOG_DIR, LLM_LOG_PATH, AMBIENCE_OPTIONS_PATH, CHANGES_PATH,
|
LOG_DIR, LLM_LOG_PATH, AMBIENCE_OPTIONS_PATH, CHANGES_PATH,
|
||||||
AUDIO_DIR, TODAY,
|
AUDIO_DIR, TODAY,
|
||||||
)
|
)
|
||||||
from models import TurnResult
|
from .models import TurnResult
|
||||||
|
|
||||||
|
|
||||||
def read_file(path: Path) -> str:
|
def read_file(path: Path) -> str:
|
||||||
657
tools/engine_lib/strategies.py
Normal file
657
tools/engine_lib/strategies.py
Normal file
@ -0,0 +1,657 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
strategies.py — Generation strategies for The Chaos engine.
|
||||||
|
|
||||||
|
Contains the three-phase conversational approach and the single-call
|
||||||
|
tool-based approach. All functions are standalone — no dependency on
|
||||||
|
GameEngine (config values and callbacks are passed explicitly).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Iterator
|
||||||
|
|
||||||
|
from .models import GenerationResult, TurnResult
|
||||||
|
from .prompts import PROSE_PROMPT
|
||||||
|
from .llm import set_llm_env, call_llm
|
||||||
|
from .tools_handler import (
|
||||||
|
execute_tool, describe_tool_action, describe_change,
|
||||||
|
parse_changes_block, extract_tool_calls,
|
||||||
|
)
|
||||||
|
from .context import build_system_prompt, build_user_message, build_prose_prompt
|
||||||
|
from .validation import auto_prompt, validate_narrative
|
||||||
|
from .parsing import parse_response, log_turn_details
|
||||||
|
from . import state
|
||||||
|
|
||||||
|
|
||||||
|
# ── Synchronous (legacy) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def generate(
|
||||||
|
player_action: str | None = None,
|
||||||
|
last_narrative: str | None = None,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
temperature: float,
|
||||||
|
timeout: int,
|
||||||
|
max_tokens: int,
|
||||||
|
api_key: str | None = None,
|
||||||
|
api_base: str | None = None,
|
||||||
|
) -> GenerationResult:
|
||||||
|
"""
|
||||||
|
Synchronous generation. Calls the LLM, parses the response,
|
||||||
|
and returns a GenerationResult.
|
||||||
|
"""
|
||||||
|
system = build_system_prompt()
|
||||||
|
user = 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(model, api_key, api_base)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = litellm.completion(
|
||||||
|
model=model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=temperature,
|
||||||
|
stream=False,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
text = response.choices[0].message.content or ""
|
||||||
|
except Exception as e:
|
||||||
|
return GenerationResult(
|
||||||
|
narrative="",
|
||||||
|
error=f"LLM call failed: {e}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return parse_response(text)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Streaming (legacy) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def generate_stream(
|
||||||
|
player_action: str | None = None,
|
||||||
|
last_narrative: str | None = None,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
temperature: float,
|
||||||
|
timeout: int,
|
||||||
|
max_tokens: int,
|
||||||
|
api_key: str | None = None,
|
||||||
|
api_base: 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 = build_system_prompt()
|
||||||
|
user = 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(model, api_key, api_base)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = litellm.completion(
|
||||||
|
model=model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=temperature,
|
||||||
|
stream=True,
|
||||||
|
timeout=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(
|
||||||
|
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,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
temperature: float,
|
||||||
|
timeout: int,
|
||||||
|
max_tokens: int,
|
||||||
|
api_key: str | None = None,
|
||||||
|
api_base: str | None = 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(model, api_key, api_base)
|
||||||
|
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 = auto_prompt("")
|
||||||
|
ambience = None
|
||||||
|
debug_info = ""
|
||||||
|
changes: list[str] = []
|
||||||
|
|
||||||
|
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 = build_prose_prompt()
|
||||||
|
|
||||||
|
user = 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=model, temperature=temperature, timeout=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 = validate_narrative(book_log, model=model, temperature=temperature, timeout=timeout, 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=model, temperature=temperature, timeout=timeout,
|
||||||
|
max_tokens=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 = 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):
|
||||||
|
from paths import CHAR_PATH, WORLD_PATH
|
||||||
|
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=model, temperature=temperature, timeout=timeout,
|
||||||
|
max_tokens=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: list[str] = []
|
||||||
|
attempt_changes: list[str] = []
|
||||||
|
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")
|
||||||
|
|
||||||
|
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(
|
||||||
|
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,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
temperature: float,
|
||||||
|
timeout: int,
|
||||||
|
max_tokens: int,
|
||||||
|
api_key: str | None = None,
|
||||||
|
api_base: str | None = 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: {model} | temp={temperature} | tokens={max_tokens} | strategy={strategy_name}")
|
||||||
|
if on_debug:
|
||||||
|
on_debug("config", {"model": model, "temperature": temperature, "max_tokens": max_tokens, "strategy": strategy_name})
|
||||||
|
|
||||||
|
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 += build_prose_prompt()
|
||||||
|
|
||||||
|
user = 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(model, api_key, 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=model, temperature=temperature, timeout=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 = auto_prompt("")
|
||||||
|
ambience = None
|
||||||
|
tool_calls = []
|
||||||
|
changes: list[str] = []
|
||||||
|
phase3_errors: list[str] = []
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
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=model,
|
||||||
|
temperature=temperature,
|
||||||
|
max_tokens=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,
|
||||||
|
)
|
||||||
@ -12,8 +12,8 @@ import json
|
|||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from paths import CHAR_PATH, WORLD_PATH, LOG_DIR, TODAY
|
from .paths import CHAR_PATH, WORLD_PATH, LOG_DIR, TODAY
|
||||||
from state import read_file, validate_update_size, update_journal, append_llm_log
|
from .state import read_file, validate_update_size, update_journal, append_llm_log
|
||||||
|
|
||||||
|
|
||||||
# ── Tool Registry ───────────────────────────────────────────────────────────
|
# ── Tool Registry ───────────────────────────────────────────────────────────
|
||||||
74
tools/engine_lib/validation.py
Normal file
74
tools/engine_lib/validation.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
validation.py — Narrative quality validation for The Chaos engine.
|
||||||
|
|
||||||
|
Standalone functions — no dependency on GameEngine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
from .llm import call_llm
|
||||||
|
|
||||||
|
|
||||||
|
def auto_prompt(book_log: str = "") -> str:
|
||||||
|
"""Fallback player prompt."""
|
||||||
|
return "**What do you do?**"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_narrative(
|
||||||
|
book_log: str,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
temperature: float,
|
||||||
|
timeout: int,
|
||||||
|
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=model, temperature=temperature, timeout=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, ""
|
||||||
928
tools/run.py
928
tools/run.py
File diff suppressed because it is too large
Load Diff
97
tools/run_ambience.py
Normal file
97
tools/run_ambience.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import os
|
||||||
|
import random
|
||||||
|
from run_utils import AMBIENCE_PATH, AMBIENCE_OPTIONS_PATH, AUDIO_DIR, parse_ambience_options
|
||||||
|
|
||||||
|
try:
|
||||||
|
import miniaudio
|
||||||
|
HAS_AUDIO = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_AUDIO = False
|
||||||
|
|
||||||
|
|
||||||
|
class AmbiencePlayer:
|
||||||
|
def __init__(self):
|
||||||
|
self.current_ambience = 'silence'
|
||||||
|
self._last_mtime = 0
|
||||||
|
self._options = {}
|
||||||
|
self._device = None
|
||||||
|
self._stream = None
|
||||||
|
self._muted = False
|
||||||
|
self.load_options()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self):
|
||||||
|
return HAS_AUDIO
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ambience_name(self):
|
||||||
|
return self.current_ambience
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_muted(self):
|
||||||
|
return self._muted
|
||||||
|
|
||||||
|
def toggle_mute(self):
|
||||||
|
self._muted = not self._muted
|
||||||
|
if self._muted:
|
||||||
|
self._stop()
|
||||||
|
else:
|
||||||
|
self._load_current()
|
||||||
|
|
||||||
|
def load_options(self):
|
||||||
|
self._options = parse_ambience_options()
|
||||||
|
|
||||||
|
def _stop(self):
|
||||||
|
if self._device:
|
||||||
|
try:
|
||||||
|
self._device.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._device = None
|
||||||
|
self._stream = None
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
if not HAS_AUDIO:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
mtime = os.path.getmtime(AMBIENCE_PATH)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
if mtime == self._last_mtime:
|
||||||
|
return
|
||||||
|
self._last_mtime = mtime
|
||||||
|
try:
|
||||||
|
name = AMBIENCE_PATH.read_text().strip().lower()
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
self.current_ambience = name
|
||||||
|
self._stop()
|
||||||
|
if not self._muted and name != 'silence' and name in self._options:
|
||||||
|
self._play_current()
|
||||||
|
|
||||||
|
def _switch_to(self, name):
|
||||||
|
if name == self.current_ambience:
|
||||||
|
return
|
||||||
|
self.current_ambience = name
|
||||||
|
self._stop()
|
||||||
|
if self._muted or name == 'silence' or name not in self._options:
|
||||||
|
return
|
||||||
|
self._play_current()
|
||||||
|
|
||||||
|
def _play_current(self):
|
||||||
|
tracks = self._options.get(self.current_ambience, [])
|
||||||
|
valid = [t for t in tracks if t.exists()]
|
||||||
|
if not valid:
|
||||||
|
return
|
||||||
|
track = random.choice(valid)
|
||||||
|
try:
|
||||||
|
self._stream = miniaudio.stream_file(str(track))
|
||||||
|
self._device = miniaudio.PlaybackDevice()
|
||||||
|
self._device.start(self._stream)
|
||||||
|
except Exception:
|
||||||
|
self.current_ambience = None
|
||||||
|
self._stop()
|
||||||
|
|
||||||
|
def _load_current(self):
|
||||||
|
if self.current_ambience and self.current_ambience != 'silence':
|
||||||
|
self._play_current()
|
||||||
125
tools/run_utils.py
Normal file
125
tools/run_utils.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
BASE = Path(__file__).resolve().parent.parent
|
||||||
|
SESSION = BASE / 'session'
|
||||||
|
LOG_DIR = SESSION / 'log'
|
||||||
|
CHAR_PATH = SESSION / 'character.md'
|
||||||
|
WORLD_PATH = SESSION / 'world.md'
|
||||||
|
JOURNAL_PATH = SESSION / 'journal.md'
|
||||||
|
AMBIENCE_PATH = SESSION / 'ambience.md'
|
||||||
|
AMBIENCE_OPTIONS_PATH = SESSION / 'ambience_options.md'
|
||||||
|
BOOK_PATH = SESSION / 'book.md'
|
||||||
|
LAST_PROMPT_PATH = SESSION / 'last_prompt.md'
|
||||||
|
CHANGES_PATH = SESSION / 'changes.md'
|
||||||
|
SETTINGS_PATH = SESSION / 'settings.json'
|
||||||
|
AUDIO_DIR = SESSION / 'audio'
|
||||||
|
TODAY = date.today().isoformat()
|
||||||
|
LOG_PATH = LOG_DIR / f'{TODAY}.md'
|
||||||
|
|
||||||
|
REFRESH_SECS = 2
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_log():
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
if not LOG_PATH.exists():
|
||||||
|
LOG_PATH.write_text(f"# Session Log — {TODAY}\n\n")
|
||||||
|
_populate_if_empty()
|
||||||
|
|
||||||
|
|
||||||
|
def _populate_if_empty():
|
||||||
|
content = LOG_PATH.read_text().strip()
|
||||||
|
if content and len(content.splitlines()) > 2:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def clear_llm_log():
|
||||||
|
from engine_lib.paths import LLM_LOG_PATH
|
||||||
|
LLM_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
LLM_LOG_PATH.write_text("")
|
||||||
|
|
||||||
|
|
||||||
|
def read_todo():
|
||||||
|
if not JOURNAL_PATH.exists():
|
||||||
|
return ["—— No journal yet ——"]
|
||||||
|
lines = JOURNAL_PATH.read_text().splitlines()
|
||||||
|
in_todo = False
|
||||||
|
todo = []
|
||||||
|
for l in lines:
|
||||||
|
if l.strip().lstrip("#").strip().startswith("TODO"):
|
||||||
|
in_todo = True
|
||||||
|
continue
|
||||||
|
if l.strip().startswith("#") and in_todo:
|
||||||
|
break
|
||||||
|
if in_todo and l.strip():
|
||||||
|
todo.append(l.strip().lstrip("- "))
|
||||||
|
return todo or ["—— All done! ——"]
|
||||||
|
|
||||||
|
|
||||||
|
def read_log_tail(n=200):
|
||||||
|
if not LOG_PATH.exists():
|
||||||
|
return []
|
||||||
|
lines = LOG_PATH.read_text().splitlines()
|
||||||
|
return [l for l in lines if l.strip() and not l.startswith("#")][-n:]
|
||||||
|
|
||||||
|
|
||||||
|
def status_summary():
|
||||||
|
if not CHAR_PATH.exists():
|
||||||
|
return "no character"
|
||||||
|
lines = CHAR_PATH.read_text().splitlines()
|
||||||
|
name = "?"
|
||||||
|
health = "?"
|
||||||
|
for l in lines:
|
||||||
|
if l.startswith('**Name:**'):
|
||||||
|
name = l.split(':', 1)[1].strip().strip('_').strip('*')
|
||||||
|
if l.startswith('**Current Health:**'):
|
||||||
|
h = l.split(':', 1)[1].strip().strip('_').strip('*')
|
||||||
|
if h:
|
||||||
|
health = h
|
||||||
|
if l.startswith('**Max Health:**'):
|
||||||
|
m = l.split(':', 1)[1].strip().strip('_').strip('*')
|
||||||
|
if m and health == '?':
|
||||||
|
health = m
|
||||||
|
return f"{name} ❤ {health}"
|
||||||
|
|
||||||
|
|
||||||
|
def log_count():
|
||||||
|
return len(read_log_tail())
|
||||||
|
|
||||||
|
|
||||||
|
def load_book_pages():
|
||||||
|
if not BOOK_PATH.exists() or not BOOK_PATH.read_text().strip():
|
||||||
|
return ["*The story has not begun.*"]
|
||||||
|
text = BOOK_PATH.read_text().strip()
|
||||||
|
turns = text.split('\n## ')
|
||||||
|
pages = []
|
||||||
|
for i, t in enumerate(turns):
|
||||||
|
pages.append(t if i == 0 else '## ' + t)
|
||||||
|
return pages or ["*The story has not begun.*"]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ambience_options():
|
||||||
|
if not AMBIENCE_OPTIONS_PATH.exists():
|
||||||
|
return {}
|
||||||
|
options = {}
|
||||||
|
lines = AMBIENCE_OPTIONS_PATH.read_text().splitlines()
|
||||||
|
in_table = False
|
||||||
|
for line in lines:
|
||||||
|
s = line.strip()
|
||||||
|
if not s.startswith('|') or not s.endswith('|'):
|
||||||
|
in_table = False
|
||||||
|
continue
|
||||||
|
parts = [p.strip() for p in s.split('|')]
|
||||||
|
parts = [p for p in parts if p]
|
||||||
|
if len(parts) < 2:
|
||||||
|
continue
|
||||||
|
if not in_table:
|
||||||
|
in_table = True
|
||||||
|
continue
|
||||||
|
if all(c in '-:| ' for c in s):
|
||||||
|
continue
|
||||||
|
name = parts[0].lower()
|
||||||
|
files = [f.strip() for f in parts[1].split(',') if f.strip()]
|
||||||
|
paths = [AUDIO_DIR / f for f in files]
|
||||||
|
options[name] = paths
|
||||||
|
return options
|
||||||
157
tools/run_widgets.py
Normal file
157
tools/run_widgets.py
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from textual.app import ComposeResult
|
||||||
|
from textual.containers import Vertical
|
||||||
|
from textual.screen import Screen
|
||||||
|
from textual.widgets import Button, Input, Static
|
||||||
|
from rich.markdown import Markdown as RichMarkdown
|
||||||
|
|
||||||
|
from run_utils import (
|
||||||
|
CHAR_PATH, TODAY, REFRESH_SECS,
|
||||||
|
clear_llm_log, ensure_log, read_todo, read_log_tail,
|
||||||
|
status_summary, log_count,
|
||||||
|
)
|
||||||
|
from run_ambience import HAS_AUDIO
|
||||||
|
|
||||||
|
|
||||||
|
# module-level ref filled by ChaosTUI
|
||||||
|
app_ambience_player: object | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RollModal(Screen):
|
||||||
|
CSS = """
|
||||||
|
RollModal {
|
||||||
|
align: center middle;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
}
|
||||||
|
#roll-dialog {
|
||||||
|
width: 44;
|
||||||
|
height: auto;
|
||||||
|
padding: 2 3;
|
||||||
|
background: #2a2a3a;
|
||||||
|
border: thick #e0ad4c;
|
||||||
|
}
|
||||||
|
#roll-title {
|
||||||
|
text-style: bold;
|
||||||
|
color: #ffd93d;
|
||||||
|
text-align: center;
|
||||||
|
height: 3;
|
||||||
|
}
|
||||||
|
#roll-reason {
|
||||||
|
color: #c0b090;
|
||||||
|
text-align: center;
|
||||||
|
height: 3;
|
||||||
|
}
|
||||||
|
#roll-input {
|
||||||
|
margin: 1 0;
|
||||||
|
}
|
||||||
|
#roll-submit {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#roll-hint {
|
||||||
|
color: #888888;
|
||||||
|
text-align: center;
|
||||||
|
height: 1;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, dice: str, reason: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.dice = dice
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="roll-dialog"):
|
||||||
|
yield Static(f"[bold]🎲 ROLL {self.dice}[/bold]", id="roll-title")
|
||||||
|
yield Static(f"Reason: {self.reason}", id="roll-reason")
|
||||||
|
yield Input(placeholder="Enter the number you rolled...", id="roll-input")
|
||||||
|
yield Button("Submit", id="roll-submit", variant="primary")
|
||||||
|
yield Static("(or press Enter)", id="roll-hint")
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
self._submit(event.value)
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "roll-submit":
|
||||||
|
self._submit(self.query_one("#roll-input", Input).value)
|
||||||
|
|
||||||
|
def _submit(self, value: str) -> None:
|
||||||
|
val = value.strip()
|
||||||
|
if val:
|
||||||
|
self.dismiss(val)
|
||||||
|
|
||||||
|
|
||||||
|
class AutoStatic(Static):
|
||||||
|
def load(self):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def on_mount(self):
|
||||||
|
clear_llm_log()
|
||||||
|
ensure_log()
|
||||||
|
self.load()
|
||||||
|
self.set_interval(REFRESH_SECS, self.load)
|
||||||
|
|
||||||
|
|
||||||
|
class TodoPane(AutoStatic):
|
||||||
|
def load(self):
|
||||||
|
items = read_todo()
|
||||||
|
self.update("\n".join(f" ☐ {i}" for i in items))
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptPane(AutoStatic):
|
||||||
|
def load(self):
|
||||||
|
lines = read_log_tail()
|
||||||
|
display = "\n".join(lines[-80:])
|
||||||
|
if lines:
|
||||||
|
display += "\n\n>>--- NOW --->"
|
||||||
|
self.update(display)
|
||||||
|
self.call_after_refresh(self._scroll_bottom)
|
||||||
|
|
||||||
|
def _scroll_bottom(self):
|
||||||
|
if self.parent and hasattr(self.parent, 'scroll_end'):
|
||||||
|
self.parent.scroll_end(animate=False)
|
||||||
|
|
||||||
|
|
||||||
|
class DebugPane(Static):
|
||||||
|
MAX_LINES = 200
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._lines: list[str] = []
|
||||||
|
|
||||||
|
def append(self, text: str) -> None:
|
||||||
|
self._lines.append(text)
|
||||||
|
if len(self._lines) > self.MAX_LINES:
|
||||||
|
self._lines.pop(0)
|
||||||
|
self.update("\n".join(self._lines[-100:]))
|
||||||
|
self.call_after_refresh(self._scroll_bottom)
|
||||||
|
|
||||||
|
def _scroll_bottom(self):
|
||||||
|
if self.parent and hasattr(self.parent, 'scroll_end'):
|
||||||
|
self.parent.scroll_end(animate=False)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._lines.clear()
|
||||||
|
self.update("")
|
||||||
|
|
||||||
|
|
||||||
|
class CharPane(AutoStatic):
|
||||||
|
def load(self):
|
||||||
|
if not CHAR_PATH.exists():
|
||||||
|
self.update("*No character sheet*")
|
||||||
|
return
|
||||||
|
self.update(RichMarkdown(CHAR_PATH.read_text().strip()))
|
||||||
|
|
||||||
|
|
||||||
|
class StatusBar(AutoStatic):
|
||||||
|
def load(self):
|
||||||
|
char = status_summary()
|
||||||
|
count = log_count()
|
||||||
|
todo = len(read_todo())
|
||||||
|
music = ""
|
||||||
|
if not HAS_AUDIO:
|
||||||
|
music = " │ ♫ (install miniaudio)"
|
||||||
|
elif app_ambience_player:
|
||||||
|
name = app_ambience_player.ambience_name
|
||||||
|
music = f" │ ♫ {name}"
|
||||||
|
self.update(f"{char} │ {count} entries │ {todo} todo │ {TODAY}{music}")
|
||||||
@ -7,12 +7,17 @@ import ast
|
|||||||
|
|
||||||
MODULES = [
|
MODULES = [
|
||||||
'engine.py',
|
'engine.py',
|
||||||
'config_paths.py',
|
'engine_lib/paths.py',
|
||||||
'models.py',
|
'engine_lib/models.py',
|
||||||
'prompts.py',
|
'engine_lib/prompts.py',
|
||||||
'state.py',
|
'engine_lib/config.py',
|
||||||
'tools_handler.py',
|
'engine_lib/context.py',
|
||||||
'llm.py',
|
'engine_lib/state.py',
|
||||||
|
'engine_lib/tools_handler.py',
|
||||||
|
'engine_lib/llm.py',
|
||||||
|
'engine_lib/validation.py',
|
||||||
|
'engine_lib/parsing.py',
|
||||||
|
'engine_lib/strategies.py',
|
||||||
]
|
]
|
||||||
|
|
||||||
def check_missing_imports():
|
def check_missing_imports():
|
||||||
|
|||||||
@ -22,12 +22,17 @@ def test_engine_import():
|
|||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
modules_to_test = [
|
modules_to_test = [
|
||||||
('paths', ['BASE_DIR', 'SESSION_DIR', 'CHAR_PATH', 'LLM_LOG_PATH']),
|
('engine_lib.paths', ['BASE_DIR', 'SESSION_DIR', 'CHAR_PATH', 'LLM_LOG_PATH']),
|
||||||
('models', ['GenerationResult', 'TurnResult']),
|
('engine_lib.models', ['GenerationResult', 'TurnResult']),
|
||||||
('prompts', ['SYSTEM_PROMPT', 'PROSE_PROMPT']),
|
('engine_lib.prompts', ['SYSTEM_PROMPT', 'PROSE_PROMPT']),
|
||||||
('state', ['read_file', 'apply_state', 'append_log', 'append_llm_log']),
|
('engine_lib.config', ['load_config', 'save_config', 'get_model']),
|
||||||
('tools_handler', ['execute_tool', 'extract_tool_calls', 'TOOL_REGISTRY']),
|
('engine_lib.context', ['build_system_prompt', 'build_user_message', 'build_prose_prompt']),
|
||||||
('llm', ['call_llm', 'set_llm_env']),
|
('engine_lib.state', ['read_file', 'apply_state', 'append_log', 'append_llm_log']),
|
||||||
|
('engine_lib.tools_handler', ['execute_tool', 'extract_tool_calls', 'TOOL_REGISTRY']),
|
||||||
|
('engine_lib.llm', ['call_llm', 'set_llm_env']),
|
||||||
|
('engine_lib.validation', ['validate_narrative', 'auto_prompt']),
|
||||||
|
('engine_lib.parsing', ['parse_response', 'log_turn_details']),
|
||||||
|
('engine_lib.strategies', ['generate_with_tools', 'generate_with_tools_single']),
|
||||||
('engine', ['GameEngine']),
|
('engine', ['GameEngine']),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user