""" Pure functions that translate adventure domain objects into LLM inputs and parse LLM outputs back into domain values. Nothing in this module makes network calls or holds state. The service layer loads the data; these functions do the translation. """ import re from app.domain.models.gen_ai import GenAiChatMessage from ...domain.models.adventure import ( AdventureEntry, AdventureEntryPossibleChoice, AdventureEntryPossibleChoiceDecision, ) def build_entry_system_prompt( language_name: str, competency: str, max_entry_count: int, min_length: int, max_length: int, ) -> str: halfway = max(1, max_entry_count // 2) return ( f"You are a game master running a single-player TTRPG-like experience " f"to help the player practise {language_name}\n\n" f"The narrative will last {max_entry_count} entries, so make them count. " f"Narratively, you are encouraged to respond to the player's pace - players who " f"want a narrative-driven piece should be indulged in dialogue and backstory; players " f"who escalate or investigate get heightened stakes. Don't pre-plan the ending or style from the beginning. " f"Plot twists are okay, but it's not a melodrama. By turn {max_entry_count} the story must conclude clearly. " f"Write like a native {language_name} writer, write with economy and confidence. Favour scene over summary. " f"Show, don't tell - tell the player what they notice (see, think, glimpse, remember, etc.) " f"Trust and respect the player's intelligence, resist formulaic or random options and outcomes.\n\n" f"When generating the options for the player, tailor them to the scenario and character that is emerging, " f"don't present 4 scattered, random options every time. Later in a narrative, the options should be more similar.\n\n " f'Format — your response must have exactly three parts, each separated by a line containing only "-----":\n' f"Part 1: story passage, {min_length}–{max_length} words, in second person, " f"written entirely in {language_name} at {competency} CEFR level. Plaintext only, no markdown.\n" f'Part 2: exactly 4 numbered options ("1." through "4."), one per line, in {language_name}.\n' f"Part 3: GM notes. These won't be shown to the player. Use these to help future LLM calls generate " f"a better TTRPG experience. This may include hidden details, juicy resolutions or twists, your thoughts " "on the type of options to generate/avoid, or anything that might pay off later. GM notes can be empty.\n" f"No sexual content or graphic violence. Romance, threat, and adventure are fine. " f"12-certificate." ) """ SECTION: Title generation prompts """ def build_title_system_prompt() -> str: return ( "You are a creative writing assistant. Given the opening passage of a choose-your-own-adventure " "story, and the Game Master's notes, generate a title and a one-sentence description for it.\n\n" "Respond with exactly two lines of plain text:\n" "Line 1: the title (max 12 words)\n" "Line 2: the description (max 200 characters, no quotes or labels)\n\n" "Avoid the following tropes: 'The secret of [noun]', 'The [noun] of [noun]'" ) def build_title_user_message( first_entry_text: str, language_name: str, genres: list[str], gamemaster_notes: str, ) -> str: return ( f"This is the opening passage of a {', '.join(genres)} adventure written in {language_name}:\n\n" f"{first_entry_text}\n\n" f"The gamemaster has provided the following (hidden from the player) notes. " f"Consider using non-spoiler details:\n{gamemaster_notes}" ) def parse_title_response(text: str) -> tuple[str, str]: """Parse a two-line title/description response. Returns (title, description). Falls back gracefully if only one line is present. """ lines = [l.strip() for l in text.strip().splitlines() if l.strip()] title = lines[0][:60] if lines else "Untitled Adventure" description = lines[1][:200] if len(lines) > 1 else "" return title, description def build_initial_user_message( genres: list[str], setting: list[str], vibes: list[str], protagonist: list[str], ) -> str: return ( "Please begin the adventure with the following details:\n" f"- Genre: {', '.join(genres)}\n" f"- Setting: {', '.join(setting)}\n" f"- Vibes: {', '.join(vibes)}\n" f"- Protagonist: {', '.join(protagonist)}\n\n" "This entry will be used to title the adventure, so include clear hints about the overall story and main character." ) def reconstruct_assistant_message( entry: AdventureEntry, choices: list[AdventureEntryPossibleChoice], ) -> str: """Rebuild the original three-part LLM response from stored entry data.""" options_block = "\n".join( f"{c.label}. {c.text}" for c in sorted(choices, key=lambda c: c.index) ) gm_block = entry.gamemaster_notes or "no notes" return f"{entry.story_text}\n-----\n{options_block}\n-----\n{gm_block}" def reconstruct_choice_message( choice_label: str, choice_index: int, max_entry_count: int ) -> str: return f"Choice: {choice_label}. Please use this to generate entry {choice_index + 1} of {max_entry_count}." def build_conversation_messages( genres: list[str], setting: list[str], vibes: list[str], protagonist: list[str], prior_entries_with_choices: list[ tuple[AdventureEntry, list[AdventureEntryPossibleChoice], str | None] ], max_entry_count: int, ) -> list[GenAiChatMessage]: """Build the full messages array for an Anthropic API call. prior_entries is a list of (entry, choices_for_that_entry, selected_choice_id). The chosen label is the label of the option the player picked to advance past that entry. For the most recent completed entry it will be None (no choice made yet). """ first_message = GenAiChatMessage( actor="user", content=build_initial_user_message(genres, setting, vibes, protagonist), ) messages: list[GenAiChatMessage] = [first_message] for index, (entry, choices, selected_choice_id) in enumerate( prior_entries_with_choices ): chosen_choice = next((c for c in choices if c.id == selected_choice_id), None) if selected_choice_id is None or chosen_choice is None: # We have a problem, no decision was recorded for this entry print(f"Warning: no decision found for entry {entry.id}") continue messages.append( GenAiChatMessage( actor="agent", content=reconstruct_assistant_message(entry, choices) ) ) messages.append( GenAiChatMessage( actor="user", content=reconstruct_choice_message( chosen_choice.label, index, max_entry_count ), ) ) return messages def parse_entry_response(text: str) -> tuple[str, list[tuple[str, str]], str]: """Parse a three-part LLM entry response. Returns (story_text, choices, gm_notes). choices is a list of (label, text) pairs e.g. [("1", "Go into the house"), ...]. Raises ValueError if the format cannot be parsed. """ parts = text.split("\n-----\n") if len(parts) < 3: parts = text.split("-----\n") if len(parts) < 3: raise ValueError(f"LLM response has {len(parts)} section(s); expected 3") story_text = parts[0].strip() options_raw = parts[1].strip() gm_notes = "\n-----\n".join(parts[2:]).strip() choices: list[tuple[str, str]] = [] for line in options_raw.splitlines(): line = line.strip() if not line: continue m = re.match(r"^(\d+)[.)]\s+(.+)$", line) if m: choices.append((m.group(1), m.group(2).strip())) if not choices: raise ValueError("No choices parsed from LLM response options section") return story_text, choices, gm_notes