""" 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 ...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 choose-your-own-adventure story " f"to help the player practise {language_name}, write like a native. \n\n" f"The session is {max_entry_count} turns. Deliver a satisfying narrative arc: " f"establish, complicate, escalate, resolve. Don't force convergence until turn {halfway}. " f"By turn {max_entry_count} the story must conclude clearly. " f"Track the character the player is building through their choices and reflect it back.\n\n" f"Write with economy and confidence. Favour scene over summary. " f"Use dialogue to reveal character rather than reporting what was said. " f"Resist the urge to over-explain — trust the player.\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 — three lines only:\n" f" Character: one sentence on what this player's choices reveal about them. When empty, write 'None'.\n" f" Threads: unresolved plot points or planted details that should pay off later.\n" f" Next beat: what the next turn needs to do narratively.\n" f" Do not describe unchosen options or recap what just happened.\n\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, generate a short title and a one-sentence description for it.\n\n" "Respond with exactly two lines of plain text:\n" "Line 1: the title (max 60 characters, no quotes or labels)\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 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]], ) -> list[dict]: """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). """ messages: list[dict] = [ {"role": "user", "content": build_initial_user_message(genres, setting, vibes, protagonist)} ] for entry, choices, selected_choice_id in 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( {"role": "assistant", "content": reconstruct_assistant_message(entry, choices)} ) messages.append({"role": "user", "content": chosen_choice.label}) 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