Journal for generation LLM

This commit is contained in:
Dejvino 2026-07-03 21:15:37 +02:00
parent b6f56f22fd
commit 52deb1db6a
6 changed files with 84 additions and 32 deletions

View File

@ -168,7 +168,8 @@ class GameEngine:
user_prompt=f"*Your action:\n\t\"{player_action}\"\nwas rejected:\n\t{reason}*", user_prompt=f"*Your action:\n\t\"{player_action}\"\nwas rejected:\n\t{reason}*",
) )
elif action == "regenerate" and attempt < MAX_RETRIES: elif action == "regenerate" and attempt < MAX_RETRIES:
feedback = f"The generated turn has issues: {reason}\n\nPlease regenerate the turn addressing this feedback. Keep the same player action but fix the problems described above." validator_tool = json.dumps({"tool": "validate", "args": {"valid": False, "reason": reason, "action": "regenerate"}})
feedback = f"The validation tool returned:\n```tool\n{validator_tool}\n```\n\nPlease regenerate the turn addressing the issues above. Keep the same player action but fix the problems described."
state.append_llm_log(f"\n[TURN REGENERATE] attempt {attempt + 2}: {reason}") state.append_llm_log(f"\n[TURN REGENERATE] attempt {attempt + 2}: {reason}")
continue continue
else: else:

View File

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from .paths import CHAR_PATH, WORLD_PATH from .paths import CHAR_PATH, WORLD_PATH, JOURNAL_PATH
from .prompts import SYSTEM_PROMPT from .prompts import SYSTEM_PROMPT
from . import state from . import state
@ -10,7 +10,8 @@ def build_system_prompt(recent_narrative: str | None = None, recent_log: str | N
char = state.read_file(CHAR_PATH) or "*No character sheet.*" char = state.read_file(CHAR_PATH) or "*No character sheet.*"
world = state.truncate_world(state.read_file(WORLD_PATH) or "") or "*No world state.*" world = state.truncate_world(state.read_file(WORLD_PATH) or "") or "*No world state.*"
log = recent_log if recent_log is not None else state.read_recent_log() log = recent_log if recent_log is not None else state.read_recent_log()
journal = state.read_file(JOURNAL_PATH) or "*No journal entries.*"
story = recent_narrative if recent_narrative is not None else state.read_recent_book(2) story = recent_narrative if recent_narrative is not None else state.read_recent_book(2)
return SYSTEM_PROMPT.substitute( return SYSTEM_PROMPT.substitute(
character=char, world=world, log=log, story=story, character=char, world=world, log=log, journal=journal, story=story,
) )

View File

@ -73,5 +73,10 @@ $world
### Log ### Log
$log $log
### Journal (TODO / DONE)
$journal
**journal_update rule**: When calling `journal_update`, you MUST use the EXACT wording of the TODO items from the Journal above. Do not rephrase, paraphrase, or invent alternate descriptions match the TODO text character-for-character. Mark items as `done` exactly as they appear in TODO. Add new items with exact wording matching their entry in the list.
### Story ### Story
$story""") $story""")

View File

@ -4,7 +4,7 @@ import json
import re import re
from .llm import call_llm from .llm import call_llm
from .paths import CHAR_PATH, WORLD_PATH from .paths import CHAR_PATH, WORLD_PATH, JOURNAL_PATH
from . import state from . import state
@ -108,6 +108,7 @@ TURN_VALIDATION_PROMPT = """You are a strict RPG game master validating a genera
3. **State Correctness**: Do the planned state changes match the narrative? Are they valid given current state? 3. **State Correctness**: Do the planned state changes match the narrative? Are they valid given current state?
4. **Self-Contained Narrative**: The narrative must read clearly on its own explicitly describe what the character did in response to the action. Do not skip the character's action and jump straight to consequences. Each turn must make sense without referencing the player action line. 4. **Self-Contained Narrative**: The narrative must read clearly on its own explicitly describe what the character did in response to the action. Do not skip the character's action and jump straight to consequences. Each turn must make sense without referencing the player action line.
5. **Log Entry**: Does the log entry accurately summarise the narrative in 1-2 short, dense sentences? Should be specific, factual, and immediately readable. 5. **Log Entry**: Does the log entry accurately summarise the narrative in 1-2 short, dense sentences? Should be specific, factual, and immediately readable.
6. **Journal Progress**: Are TODO items being addressed? If the narrative resolves an open TODO, the turn must call `journal_update` to mark it done. Unchecked items left stale too long may need prompting.
## Character (before changes) ## Character (before changes)
{character} {character}
@ -121,6 +122,9 @@ TURN_VALIDATION_PROMPT = """You are a strict RPG game master validating a genera
## Session Log ## Session Log
{log} {log}
## Journal
{journal}
## Player Action ## Player Action
{action} {action}
@ -142,6 +146,7 @@ Check all criteria. **Completeness** is critical — scan the narrative for ever
- **Cash changed** must have `modify_vitals` - **Cash changed** must have `modify_vitals`
- **World changed** must have `world_update` - **World changed** must have `world_update`
- **NPC/location/thread changes** must have `world_update` or `add_note` - **NPC/location/thread changes** must have `world_update` or `add_note`
- **TODO resolved** must have `journal_update` with `done`
Missing tool calls = regenerate. Also check that: Missing tool calls = regenerate. Also check that:
- The narrative explicitly describes the character acting not just the world reacting - The narrative explicitly describes the character acting not just the world reacting
@ -153,21 +158,21 @@ Missing tool calls = regenerate. Also check that:
For log entry: must be a tight summary of the narrative's key events — specific entities, actions, outcomes. Vague, rambling, or mismatched log entries should be flagged for regenerate. For log entry: must be a tight summary of the narrative's key events — specific entities, actions, outcomes. Vague, rambling, or mismatched log entries should be flagged for regenerate.
Reply with ONLY a JSON object using one of these formats: Reply with ONLY a ```tool block. Examples:
Valid: Valid:
```json ```tool
{{"valid": true, "reason": "ok", "action": "ok"}} {{"tool": "validate", "args": {{"valid": true, "reason": "ok", "action": "ok"}}}}
``` ```
Reject (player action itself was impossible or nonsensical): Reject (player action itself was impossible or nonsensical):
```json ```tool
{{"valid": false, "reason": "explain why the action is impossible", "action": "reject"}} {{"tool": "validate", "args": {{"valid": false, "reason": "explain why the action is impossible", "action": "reject"}}}}
``` ```
Regenerate (turn had fixable issues like wrong state changes or minor inconsistencies): Regenerate (turn had fixable issues like wrong state changes or minor inconsistencies):
```json ```tool
{{"valid": false, "reason": "describe what the LLM should fix", "action": "regenerate"}} {{"tool": "validate", "args": {{"valid": false, "reason": "describe what the LLM should fix", "action": "regenerate"}}}}
``` ```
""" """
@ -206,11 +211,12 @@ def validate_turn(
world = state.truncate_world(state.read_file(WORLD_PATH) or "") or "*No world state.*" world = state.truncate_world(state.read_file(WORLD_PATH) or "") or "*No world state.*"
recent = story.strip() or state.read_recent_book() or "*No prior story.*" recent = story.strip() or state.read_recent_book() or "*No prior story.*"
log_entries = log.strip() or state.read_recent_log() or "*No recent events.*" log_entries = log.strip() or state.read_recent_log() or "*No recent events.*"
journal = state.read_file(JOURNAL_PATH) or "*No journal entries.*"
change_summary = _format_changes(changes or []) change_summary = _format_changes(changes or [])
prompt = TURN_VALIDATION_PROMPT.format( prompt = TURN_VALIDATION_PROMPT.format(
character=char, world=world, story=recent, character=char, world=world, story=recent,
log=log_entries, action=player_action, log=log_entries, journal=journal, action=player_action,
narrative=narrative, log_entry=log_entry or "*No log entry provided.*", narrative=narrative, log_entry=log_entry or "*No log entry provided.*",
changes=change_summary, changes=change_summary,
) )
@ -229,15 +235,19 @@ def validate_turn(
if not text: if not text:
return False, "Not sure", "reject" return False, "Not sure", "reject"
cleaned = text.strip() m = re.search(r"```tool\s*\n?(.*?)```", text, re.DOTALL)
m = re.search(r"```(?:json)?\s*\n?(.*?)```", cleaned, re.DOTALL)
if m: if m:
cleaned = m.group(1).strip() cleaned = m.group(1).strip()
else:
cleaned = text.strip()
try: try:
data = json.loads(cleaned) data = json.loads(cleaned)
valid = data.get("valid", True) if data.get("tool") != "validate":
reason = data.get("reason", "") return False, "Unrecognized", "reject"
action = data.get("action", "ok") args = data.get("args", {})
valid = args.get("valid", True)
reason = args.get("reason", "")
action = args.get("action", "ok")
if action not in ("ok", "reject", "regenerate"): if action not in ("ok", "reject", "regenerate"):
action = "ok" if valid else "reject" action = "ok" if valid else "reject"
if on_debug: if on_debug:
@ -249,7 +259,7 @@ def validate_turn(
if attempt == 0: if attempt == 0:
messages.append({ messages.append({
"role": "system", "role": "system",
"content": "Your previous response was not valid JSON. Reply with ONLY a JSON object:\n\n```json\n{\"valid\": true, \"reason\": \"ok\", \"action\": \"ok\"}\n```\nor\n```json\n{\"valid\": false, \"reason\": \"...\", \"action\": \"reject\"}\n```\nor\n```json\n{\"valid\": false, \"reason\": \"...\", \"action\": \"regenerate\"}\n```" "content": "Your previous response was not valid. Reply with ONLY a ```tool block:\n\n```tool\n{\"tool\": \"validate\", \"args\": {\"valid\": true, \"reason\": \"ok\", \"action\": \"ok\"}}\n```\nor\n```tool\n{\"tool\": \"validate\", \"args\": {\"valid\": false, \"reason\": \"...\", \"action\": \"reject\"}}\n```\nor\n```tool\n{\"tool\": \"validate\", \"args\": {\"valid\": false, \"reason\": \"...\", \"action\": \"regenerate\"}}\n```"
}) })
return False, "Unrecognized", "reject" return False, "Unrecognized", "reject"

View File

@ -209,10 +209,9 @@ class ChaosTUI(App):
parts = [] parts = []
parts.append(pages[-1]) parts.append(pages[-1])
if CHANGES_PATH.exists(): if CHANGES_PATH.exists():
changes = [l.strip() for l in CHANGES_PATH.read_text().splitlines() if l.strip()] saved = [l.strip() for l in CHANGES_PATH.read_text().splitlines() if l.strip()]
if changes: if saved:
changes_text = "\n".join(f"> {c}" for c in changes) parts.append(self._render_changes(saved))
parts.append(f"> **Last turn changes:**\n{changes_text}")
self._set_narrative("\n\n".join(parts)) self._set_narrative("\n\n".join(parts))
self._enable_input() self._enable_input()
return return
@ -460,12 +459,16 @@ class ChaosTUI(App):
self._append_debug(f" {line}") self._append_debug(f" {line}")
self._show_error(err_msg, traceback_str) self._show_error(err_msg, traceback_str)
@staticmethod
def _render_changes(changes: list[str]) -> str:
return "**Changes:**\n" + "\n".join(f"- {c}" for c in changes)
def _display_scene(self, result: TurnResult) -> None: def _display_scene(self, result: TurnResult) -> None:
parts = [] parts = []
if result.book_log: if result.book_log:
parts.append(result.book_log) parts.append(result.book_log)
if result.changes: if result.changes:
parts.append(f"> **Changes:**\n" + "\n".join(f"> {c}" for c in result.changes)) parts.append(self._render_changes(result.changes))
if result.user_prompt: if result.user_prompt:
parts.append(f"---\n\n{result.user_prompt}") parts.append(f"---\n\n{result.user_prompt}")
self._set_narrative("\n\n".join(parts) if parts else "") self._set_narrative("\n\n".join(parts) if parts else "")

View File

@ -140,15 +140,47 @@ def test_turn_empty_inputs():
print("✓ empty inputs returns (True, '', 'ok')") print("✓ empty inputs returns (True, '', 'ok')")
def _mock_read(p: str) -> str:
"""Helper for mock_read_file side_effect handling char/world/journal."""
low = str(p).lower()
if "character" in low:
return "HP: 10\nGold: 5\nInventory:\n- Healing Salve"
if "journal" in low:
return "# Journal\n\n## TODO\n- Find the relic\n\n## DONE\n"
return "## Location\nTavern"
def _mock_read_no_gold(p: str) -> str:
low = str(p).lower()
if "character" in low:
return "HP: 10\nGold: 0"
if "journal" in low:
return "# Journal\n\n## TODO\n\n## DONE\n"
return "## Location\nTavern"
def _mock_read_basic(p: str) -> str:
low = str(p).lower()
if "character" in low:
return "HP: 10"
if "journal" in low:
return "# Journal\n\n## TODO\n\n## DONE\n"
return "## Location\nTavern"
def _tool_response(valid: bool, reason: str, action: str) -> str:
return f'```tool\n{{"tool": "validate", "args": {{"valid": {str(valid).lower()}, "reason": "{reason}", "action": "{action}"}}}}\n```'
@patch("engine_lib.validation.state.read_file") @patch("engine_lib.validation.state.read_file")
@patch("engine_lib.validation.state.truncate_world") @patch("engine_lib.validation.state.truncate_world")
@patch("engine_lib.validation.call_llm") @patch("engine_lib.validation.call_llm")
def test_turn_valid(mock_call_llm, mock_truncate_world, mock_read_file): def test_turn_valid(mock_call_llm, mock_truncate_world, mock_read_file):
from engine_lib.validation import validate_turn from engine_lib.validation import validate_turn
mock_read_file.side_effect = lambda p: "HP: 10\nGold: 5\nInventory:\n- Healing Salve" if "character" in str(p).lower() else "## Location\nTavern" mock_read_file.side_effect = _mock_read
mock_truncate_world.return_value = "## Location\nTavern" mock_truncate_world.return_value = "## Location\nTavern"
mock_call_llm.return_value = '{"valid": true, "reason": "ok", "action": "ok"}' mock_call_llm.return_value = _tool_response(True, "ok", "ok")
valid, reason, action = validate_turn( valid, reason, action = validate_turn(
"I use my healing salve", "I use my healing salve",
@ -172,9 +204,9 @@ def test_turn_valid(mock_call_llm, mock_truncate_world, mock_read_file):
def test_turn_reject(mock_call_llm, mock_truncate_world, mock_read_file): def test_turn_reject(mock_call_llm, mock_truncate_world, mock_read_file):
from engine_lib.validation import validate_turn from engine_lib.validation import validate_turn
mock_read_file.side_effect = lambda p: "HP: 10\nGold: 0" if "character" in str(p).lower() else "## Location\nTavern" mock_read_file.side_effect = _mock_read_no_gold
mock_truncate_world.return_value = "## Location\nTavern" mock_truncate_world.return_value = "## Location\nTavern"
mock_call_llm.return_value = '{"valid": false, "reason": "Player has no gold", "action": "reject"}' mock_call_llm.return_value = _tool_response(False, "Player has no gold", "reject")
valid, reason, action = validate_turn( valid, reason, action = validate_turn(
"I buy a round for the house", "I buy a round for the house",
@ -197,9 +229,9 @@ def test_turn_reject(mock_call_llm, mock_truncate_world, mock_read_file):
def test_turn_regenerate(mock_call_llm, mock_truncate_world, mock_read_file): def test_turn_regenerate(mock_call_llm, mock_truncate_world, mock_read_file):
from engine_lib.validation import validate_turn from engine_lib.validation import validate_turn
mock_read_file.side_effect = lambda p: "HP: 10\nInventory:\n- Healing Salve" if "character" in str(p).lower() else "## Location\nTavern" mock_read_file.side_effect = _mock_read
mock_truncate_world.return_value = "## Location\nTavern" mock_truncate_world.return_value = "## Location\nTavern"
mock_call_llm.return_value = '{"valid": false, "reason": "Narrative says salve used but no remove_from_inventory", "action": "regenerate"}' mock_call_llm.return_value = _tool_response(False, "Narrative says salve used but no remove_from_inventory", "regenerate")
valid, reason, action = validate_turn( valid, reason, action = validate_turn(
"I use my healing salve", "I use my healing salve",
@ -222,7 +254,7 @@ def test_turn_regenerate(mock_call_llm, mock_truncate_world, mock_read_file):
def test_turn_bad_json(mock_call_llm, mock_truncate_world, mock_read_file): def test_turn_bad_json(mock_call_llm, mock_truncate_world, mock_read_file):
from engine_lib.validation import validate_turn from engine_lib.validation import validate_turn
mock_read_file.side_effect = lambda p: "HP: 10" if "character" in str(p).lower() else "## Location\nTavern" mock_read_file.side_effect = _mock_read_basic
mock_truncate_world.return_value = "## Location\nTavern" mock_truncate_world.return_value = "## Location\nTavern"
mock_call_llm.return_value = "not valid json" mock_call_llm.return_value = "not valid json"
@ -247,9 +279,9 @@ def test_turn_bad_json(mock_call_llm, mock_truncate_world, mock_read_file):
def test_turn_on_debug(mock_call_llm, mock_truncate_world, mock_read_file): def test_turn_on_debug(mock_call_llm, mock_truncate_world, mock_read_file):
from engine_lib.validation import validate_turn from engine_lib.validation import validate_turn
mock_read_file.side_effect = lambda p: "HP: 10" if "character" in str(p).lower() else "## Location\nTavern" mock_read_file.side_effect = _mock_read_basic
mock_truncate_world.return_value = "## Location\nTavern" mock_truncate_world.return_value = "## Location\nTavern"
mock_call_llm.return_value = '{"valid": true, "reason": "ok", "action": "ok"}' mock_call_llm.return_value = _tool_response(True, "ok", "ok")
events = [] events = []
def debug_cb(key, data): def debug_cb(key, data):