import json import os import random from services.llm import LLMError, send_message_with_model import logging logger = logging.getLogger(__name__) NARRATOR_MODEL = os.getenv("RPG_NARRATOR_MODEL", "").strip() or "deepseek/deepseek-chat-v3" NARRATOR_PRE_SYSTEM = """You are the System/Narrator of an RPG chat. Decide if the user's action requires a skill/ability check (physical action, persuasion, deception, stealth, combat, etc.). Pure dialogue, questions, or passive observation do NOT require a check. Return ONLY valid JSON (no markdown): { "needs_check": true, "check_reason": "brief reason why a check is needed (e.g. 'jumping over a pit')", "directives": ["short imperative rules for the next character reply"], "resolution_text": "what actually happens as result of the action — written as narrator prose (1-2 sentences). Only if needs_check=true and roll/outcome provided.", "status_quo_update": "optional short update about the world state" } If needs_check=false: directives may still guide tone/pacing, resolution_text must be empty string. If needs_check=true and roll/outcome are provided: resolution_text MUST reflect the outcome. - critical failure (1): embarrassing or painful failure with extra complication - failure (2-8): action fails, partial or no progress - success (9-19): action succeeds as intended - critical success (20): spectacular success with bonus effect""" NARRATOR_POST_SYSTEM = """You are the System/Narrator of an RPG chat. After the character replied, update persistent state. Return ONLY valid JSON (no markdown): { "status_quo_update": "what changed in the world/state (1-3 sentences)", "facts": ["durable facts only"], "choices": [{"id":"a","label":"..."}, ...], "affinity_delta": 0, "quest_updates": [{"title": "quest title", "status": "active|done|failed"}], "outfit_update": ["danbooru_tag", "danbooru_tag"] } Rules: - affinity_delta: integer -2..+2. Positive if character warmed up to player, negative if pushed away. 0 if neutral. - quest_updates: only include if a quest was clearly started, completed, or failed. Empty array otherwise. - choices: 0-4 options for what the player can do next. - outfit_update: ONLY include if the character's clothing visibly changed (put on, took off, changed outfit). Use exact danbooru-style underscore_tags (e.g. ["white_dress", "red_ribbon", "barefoot"]). Empty array if no change.""" async def narrator_pre( persona_name: str, context: str, global_plot: str, facts_block: str, user_message: str, roll: int | None = None, outcome: str | None = None, ) -> dict: roll_block = f"Roll d20={roll}\nOutcome={outcome}\n\n" if roll is not None else "" user = ( f"Persona: {persona_name}\n" f"{roll_block}" f"User action: {user_message}\n\n" f"Global plot:\n{global_plot}\n\n" f"Facts:\n{facts_block}\n\n" f"Recent context:\n{context}\n" ) try: raw = await send_message_with_model( [{"role": "system", "content": NARRATOR_PRE_SYSTEM}, {"role": "user", "content": user}], NARRATOR_MODEL, ) except LLMError as e: logger.warning("Narrator-pre LLM failed (model=%s): %s", NARRATOR_MODEL, e) return {"needs_check": False, "directives": [], "status_quo_update": "", "resolution_text": ""} except Exception as e: logger.warning("Narrator-pre unexpected error: %s", e) return {"needs_check": False, "directives": [], "status_quo_update": "", "resolution_text": ""} cleaned = raw.strip() if cleaned.startswith("```"): cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned if cleaned.endswith("```"): cleaned = cleaned.rsplit("```", 1)[0] cleaned = cleaned.strip() try: data = json.loads(cleaned) if isinstance(data, dict): return data except Exception: logger.warning("Narrator-pre JSON parse failed. Raw=%.500s", raw) return {"needs_check": False, "directives": [], "status_quo_update": "", "resolution_text": ""} async def narrator_post( persona_name: str, context: str, global_plot: str, facts_block: str, is_opening: bool = False, ) -> dict: opening_block = "" if is_opening: opening_block = ( "\n\nOPENING SCENE: This is the first greeting, not a mid-conversation reply. " "Extract the character's INITIAL visible clothing from the greeting into outfit_update " "(danbooru underscore tags), even if clothing did not change during the scene. " "Set status_quo to describe the opening situation.\n" ) user = ( f"Persona: {persona_name}\n\n" f"Global plot:\n{global_plot}\n\n" f"Facts:\n{facts_block}\n\n" f"Recent context:\n{context}\n" f"{opening_block}" ) try: raw = await send_message_with_model( [{"role": "system", "content": NARRATOR_POST_SYSTEM}, {"role": "user", "content": user}], NARRATOR_MODEL, ) except LLMError as e: logger.warning("Narrator-post LLM failed (model=%s): %s", NARRATOR_MODEL, e) return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": []} except Exception as e: logger.warning("Narrator-post unexpected error: %s", e) return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": []} cleaned = raw.strip() if cleaned.startswith("```"): cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned if cleaned.endswith("```"): cleaned = cleaned.rsplit("```", 1)[0] cleaned = cleaned.strip() try: data = json.loads(cleaned) if isinstance(data, dict): return data except Exception: logger.warning("Narrator-post JSON parse failed. Raw=%.500s", raw) return {"status_quo_update": "", "facts": [], "choices": [], "affinity_delta": 0, "quest_updates": []}