""" 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 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 an experienced tabletop game master running a single-player one-shot campaign " f"in a \"choose your own adventure\" format.\n\n" f"You are helping the player learn {language_name}. Your writing respects their " f"intelligence, avoids too many clichés, delivers satisfying plot beats, and reads naturally.\n\n" f"The session is {max_entry_count} turns. Each turn: you write a story passage, then offer " f"4 numbered choices. The player replies with their choice; you continue accordingly. " f"By turn {max_entry_count} there needs to be a clear end. As the player's choices reveal " f"their character, weave those details back into the story. " f"Don't railroad them until at least turn {halfway}.\n\n" f"Rules:\n" f"- Write entirely in {language_name} at {competency} level on the CEFR scale. " f"No markdown — plaintext only.\n" f"- Your response MUST be in exactly three parts, each separated by a line containing " f"only \"-----\".\n" f"- Part 1: the story entry, {min_length}–{max_length} words, speaking directly to the player.\n" f"- Part 2: exactly 4 numbered player options, one per line, labelled \"1.\", \"2.\", \"3.\", \"4.\".\n" f"- Part 3: GM notes to your future self (hidden from the player). \n" f"If no notes, write \"no notes\".\n" f"- Your first message must establish: who the player is, the setting, and the broad direction.\n" f"- No sexual content or graphic violence. Romance, threat, and adventure are fine (12-certificate)." ) 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)" ) 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)}" ) def build_title_user_message( first_entry_text: str, language_name: str, genres: list[str], ) -> str: return ( f"This is the opening passage of a {', '.join(genres)} adventure written in {language_name}:\n\n" f"{first_entry_text}" ) 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: list[tuple[AdventureEntry, list[AdventureEntryPossibleChoice], str | None]], prior_decisions: list[AdventureEntryPossibleChoice | None], ) -> list[dict]: """Build the full messages array for an Anthropic API call. prior_entries is a list of (entry, choices_for_that_entry, chosen_label_or_None). 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, chosen_label in prior_entries: messages.append( {"role": "assistant", "content": reconstruct_assistant_message(entry, choices)} ) # Find the player's decision for this entry choice_ids = [c.id for c in choices] decision_for_entry = next( (d for d in prior_decisions if d and d.choice_id in choice_ids), None ) # If a decision exists, append the player's chosen option if decision_for_entry: chosen_option = next( (c for c in choices if c.id == decision_for_entry.choice_id), None ) if chosen_option: messages.append({"role": "user", "content": chosen_option.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 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