From 52deb1db6aa3b2f6e5532154fb3d8ed87f70e77f Mon Sep 17 00:00:00 2001 From: Dejvino Date: Fri, 3 Jul 2026 21:15:37 +0200 Subject: [PATCH] Journal for generation LLM --- tools/engine.py | 3 +- tools/engine_lib/context.py | 5 ++-- tools/engine_lib/prompts.py | 5 ++++ tools/engine_lib/validation.py | 40 +++++++++++++++++---------- tools/run.py | 13 +++++---- tools/test_validation.py | 50 ++++++++++++++++++++++++++++------ 6 files changed, 84 insertions(+), 32 deletions(-) diff --git a/tools/engine.py b/tools/engine.py index 0a6a095..33166ac 100644 --- a/tools/engine.py +++ b/tools/engine.py @@ -168,7 +168,8 @@ class GameEngine: user_prompt=f"*Your action:\n\t\"{player_action}\"\nwas rejected:\n\t{reason}*", ) 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}") continue else: diff --git a/tools/engine_lib/context.py b/tools/engine_lib/context.py index 0bee4d9..cf14b0b 100644 --- a/tools/engine_lib/context.py +++ b/tools/engine_lib/context.py @@ -1,6 +1,6 @@ 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 . 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.*" 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() + 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) return SYSTEM_PROMPT.substitute( - character=char, world=world, log=log, story=story, + character=char, world=world, log=log, journal=journal, story=story, ) diff --git a/tools/engine_lib/prompts.py b/tools/engine_lib/prompts.py index 7a01d4f..284a981 100644 --- a/tools/engine_lib/prompts.py +++ b/tools/engine_lib/prompts.py @@ -73,5 +73,10 @@ $world ### 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""") diff --git a/tools/engine_lib/validation.py b/tools/engine_lib/validation.py index b8095c4..e3c5ba7 100644 --- a/tools/engine_lib/validation.py +++ b/tools/engine_lib/validation.py @@ -4,7 +4,7 @@ import json import re from .llm import call_llm -from .paths import CHAR_PATH, WORLD_PATH +from .paths import CHAR_PATH, WORLD_PATH, JOURNAL_PATH 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? 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. +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} @@ -121,6 +122,9 @@ TURN_VALIDATION_PROMPT = """You are a strict RPG game master validating a genera ## Session Log {log} +## Journal +{journal} + ## Player Action {action} @@ -142,6 +146,7 @@ Check all criteria. **Completeness** is critical — scan the narrative for ever - **Cash changed** → must have `modify_vitals` - **World changed** → must have `world_update` - **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: - 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. -Reply with ONLY a JSON object using one of these formats: +Reply with ONLY a ```tool block. Examples: Valid: -```json -{{"valid": true, "reason": "ok", "action": "ok"}} +```tool +{{"tool": "validate", "args": {{"valid": true, "reason": "ok", "action": "ok"}}}} ``` Reject (player action itself was impossible or nonsensical): -```json -{{"valid": false, "reason": "explain why the action is impossible", "action": "reject"}} +```tool +{{"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): -```json -{{"valid": false, "reason": "describe what the LLM should fix", "action": "regenerate"}} +```tool +{{"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.*" 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.*" + journal = state.read_file(JOURNAL_PATH) or "*No journal entries.*" change_summary = _format_changes(changes or []) prompt = TURN_VALIDATION_PROMPT.format( 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.*", changes=change_summary, ) @@ -229,15 +235,19 @@ def validate_turn( if not text: return False, "Not sure", "reject" - cleaned = text.strip() - m = re.search(r"```(?:json)?\s*\n?(.*?)```", cleaned, re.DOTALL) + m = re.search(r"```tool\s*\n?(.*?)```", text, re.DOTALL) if m: cleaned = m.group(1).strip() + else: + cleaned = text.strip() try: data = json.loads(cleaned) - valid = data.get("valid", True) - reason = data.get("reason", "") - action = data.get("action", "ok") + if data.get("tool") != "validate": + return False, "Unrecognized", "reject" + 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"): action = "ok" if valid else "reject" if on_debug: @@ -249,7 +259,7 @@ def validate_turn( if attempt == 0: messages.append({ "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" diff --git a/tools/run.py b/tools/run.py index 510b374..6926c0f 100755 --- a/tools/run.py +++ b/tools/run.py @@ -209,10 +209,9 @@ class ChaosTUI(App): parts = [] parts.append(pages[-1]) if CHANGES_PATH.exists(): - changes = [l.strip() for l in CHANGES_PATH.read_text().splitlines() if l.strip()] - if changes: - changes_text = "\n".join(f"> {c}" for c in changes) - parts.append(f"> **Last turn changes:**\n{changes_text}") + saved = [l.strip() for l in CHANGES_PATH.read_text().splitlines() if l.strip()] + if saved: + parts.append(self._render_changes(saved)) self._set_narrative("\n\n".join(parts)) self._enable_input() return @@ -460,12 +459,16 @@ class ChaosTUI(App): self._append_debug(f" {line}") 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: parts = [] if result.book_log: parts.append(result.book_log) 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: parts.append(f"---\n\n{result.user_prompt}") self._set_narrative("\n\n".join(parts) if parts else "") diff --git a/tools/test_validation.py b/tools/test_validation.py index 152c051..ff74368 100644 --- a/tools/test_validation.py +++ b/tools/test_validation.py @@ -140,15 +140,47 @@ def test_turn_empty_inputs(): 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.truncate_world") @patch("engine_lib.validation.call_llm") def test_turn_valid(mock_call_llm, mock_truncate_world, mock_read_file): 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_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( "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): 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_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( "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): 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_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( "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): 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_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): 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_call_llm.return_value = '{"valid": true, "reason": "ok", "action": "ok"}' + mock_call_llm.return_value = _tool_response(True, "ok", "ok") events = [] def debug_cb(key, data):