Guard against duplicate narrative entries

This commit is contained in:
Dejvino 2026-07-03 21:47:43 +02:00
parent 0458811e02
commit f76470acb4
3 changed files with 48 additions and 8 deletions

View File

@ -5,6 +5,7 @@ import random
import re import re
import sys import sys
from datetime import datetime from datetime import datetime
from difflib import SequenceMatcher
from pathlib import Path from pathlib import Path
from engine_lib.models import TurnResult from engine_lib.models import TurnResult
@ -117,8 +118,14 @@ class GameEngine:
if text: if text:
book_log = (book_log + "\n\n" + text) if book_log else text book_log = (book_log + "\n\n" + text) if book_log else text
elif name == "finalize_turn": elif name == "finalize_turn":
if args.get("ambience"): raw = (args.get("ambience") or "").strip().lower()
ambience = args["ambience"] if raw:
valid = state.get_valid_ambiences()
if raw in valid:
ambience = raw
else:
state.append_llm_log(f"\n[WARN] invalid ambience '{raw}'")
ambience = None
if args.get("log_entry"): if args.get("log_entry"):
log_entry = args["log_entry"] log_entry = args["log_entry"]
elif name == "player_roll": elif name == "player_roll":
@ -126,6 +133,25 @@ class GameEngine:
elif name not in ("roll",): elif name not in ("roll",):
state_changes.append(tc) state_changes.append(tc)
# Duplicate check — reject if narrative is 80%+ similar to last book entry
if book_log:
prev = state.read_recent_book(1)
if prev and prev not in ("*No prior story.*",):
prev_text = re.sub(r"^## Turn \d+\n\n", "", prev, flags=re.MULTILINE).strip()
ratio = SequenceMatcher(None, book_log, prev_text).ratio()
if ratio >= 0.8:
state.append_llm_log(f"\n[TURN DUPLICATE] {ratio:.0%} match with previous turn")
if attempt < MAX_RETRIES:
feedback = "The narrative is nearly identical to the previous turn. Generate something new and different."
state.append_llm_log(f"\n[TURN REGENERATE] (duplicate) attempt {attempt + 2}")
continue
state.append_llm_log(f"\n[TURN DUPLICATE EXCEEDED] cannot generate unique narrative")
return TurnResult(
book_log="",
log_entry="Your action was rejected — could not generate a unique narrative.",
user_prompt=f"*Your action:\n\t\"{player_action}\"\nwas rejected:\nengine failed to produce unique narrative*",
)
# Validate the generated turn # Validate the generated turn
if on_action: if on_action:
on_action("DM is validating the response") on_action("DM is validating the response")
@ -181,8 +207,6 @@ class GameEngine:
if name == "narrative": if name == "narrative":
pass pass
elif name == "finalize_turn":
pass
elif name == "player_roll" and on_player_roll: elif name == "player_roll" and on_player_roll:
dice = args.get("dice", "1d6") dice = args.get("dice", "1d6")
reason = args.get("reason", "a check") reason = args.get("reason", "a check")

View File

@ -112,8 +112,6 @@ def validate_update_size(name: str, new_content: str, path: Path) -> bool:
def apply_state(result: TurnResult) -> None: def apply_state(result: TurnResult) -> None:
"""Write state changes from a TurnResult to disk.""" """Write state changes from a TurnResult to disk."""
if result.ambience:
AMBIENCE_PATH.write_text(result.ambience.strip().lower() + "\n")
if result.changes: if result.changes:
CHANGES_PATH.write_text("\n".join(result.changes) + "\n") CHANGES_PATH.write_text("\n".join(result.changes) + "\n")
else: else:

View File

@ -4,8 +4,8 @@ import json
import random import random
import re import re
from .paths import CHAR_PATH, WORLD_PATH from .paths import AMBIENCE_PATH, CHAR_PATH, WORLD_PATH
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, get_valid_ambiences
TOOL_REGISTRY: dict[str, dict] = { TOOL_REGISTRY: dict[str, dict] = {
@ -168,6 +168,20 @@ def tool_journal_update(args: dict) -> str:
return "Journal updated." return "Journal updated."
def tool_finalize_turn(args: dict) -> str:
"""Validate ambience and write to AMBIENCE_PATH."""
raw = (args or {}).get("ambience", "").strip().lower()
if not raw:
AMBIENCE_PATH.write_text("silence\n")
return "Ambience set to silence."
valid = get_valid_ambiences()
if raw not in valid:
append_llm_log(f"\n[WARN] invalid ambience '{raw}', allowed: {sorted(valid)}")
return f"**Error:** invalid ambience '{raw}'. Allowed: {', '.join(sorted(valid))}."
AMBIENCE_PATH.write_text(raw + "\n")
return f"Ambience set to {raw}."
def execute_tool(tool_name: str, args: dict) -> str: def execute_tool(tool_name: str, args: dict) -> str:
"""Execute a tool by name. Returns result string.""" """Execute a tool by name. Returns result string."""
fn_map = { fn_map = {
@ -181,6 +195,7 @@ def execute_tool(tool_name: str, args: dict) -> str:
"replace_note": tool_replace_note, "replace_note": tool_replace_note,
"world_update": tool_world_update, "world_update": tool_world_update,
"journal_update": tool_journal_update, "journal_update": tool_journal_update,
"finalize_turn": tool_finalize_turn,
} }
fn = fn_map.get(tool_name) fn = fn_map.get(tool_name)
if not fn: if not fn:
@ -227,6 +242,9 @@ def describe_change(tool_name: str, args: dict) -> str:
for d in args.get("done", []): for d in args.get("done", []):
parts.append(f"{d}") parts.append(f"{d}")
return "; ".join(parts) if parts else "" return "; ".join(parts) if parts else ""
elif tool_name == "finalize_turn":
a = args.get("ambience", "")
return f"{a}" if a else ""
return "" return ""