658 lines
25 KiB
Python
658 lines
25 KiB
Python
#!/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,
|
|
)
|