173 lines
6.7 KiB
Python
173 lines
6.7 KiB
Python
"""
|
||
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
|