2026-05-03 16:17:47 +00:00
|
|
|
|
"""
|
|
|
|
|
|
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.
|
|
|
|
|
|
"""
|
2026-05-27 17:45:52 +00:00
|
|
|
|
|
2026-05-03 16:17:47 +00:00
|
|
|
|
import re
|
|
|
|
|
|
|
2026-05-27 17:45:52 +00:00
|
|
|
|
from app.domain.models.gen_ai import GenAiChatMessage
|
|
|
|
|
|
|
|
|
|
|
|
from ...domain.models.adventure import (
|
|
|
|
|
|
AdventureEntry,
|
|
|
|
|
|
AdventureEntryPossibleChoice,
|
|
|
|
|
|
AdventureEntryPossibleChoiceDecision,
|
|
|
|
|
|
)
|
2026-05-03 16:17:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 (
|
2026-05-27 17:45:52 +00:00
|
|
|
|
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'
|
2026-05-08 09:58:46 +00:00
|
|
|
|
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"
|
2026-05-27 17:45:52 +00:00
|
|
|
|
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"
|
2026-05-08 09:58:46 +00:00
|
|
|
|
f"No sexual content or graphic violence. Romance, threat, and adventure are fine. "
|
|
|
|
|
|
f"12-certificate."
|
2026-05-03 16:17:47 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-27 17:45:52 +00:00
|
|
|
|
|
2026-05-08 09:58:46 +00:00
|
|
|
|
"""
|
|
|
|
|
|
SECTION: Title generation prompts
|
|
|
|
|
|
"""
|
2026-05-03 16:17:47 +00:00
|
|
|
|
|
2026-05-27 17:45:52 +00:00
|
|
|
|
|
2026-05-03 16:17:47 +00:00
|
|
|
|
def build_title_system_prompt() -> str:
|
|
|
|
|
|
return (
|
|
|
|
|
|
"You are a creative writing assistant. Given the opening passage of a choose-your-own-adventure "
|
2026-05-27 17:45:52 +00:00
|
|
|
|
"story, and the Game Master's notes, generate a title and a one-sentence description for it.\n\n"
|
2026-05-03 16:17:47 +00:00
|
|
|
|
"Respond with exactly two lines of plain text:\n"
|
2026-05-27 17:45:52 +00:00
|
|
|
|
"Line 1: the title (max 12 words)\n"
|
2026-05-08 09:58:46 +00:00
|
|
|
|
"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]'"
|
2026-05-03 16:17:47 +00:00
|
|
|
|
)
|
2026-05-27 17:45:52 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-08 09:58:46 +00:00
|
|
|
|
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}"
|
|
|
|
|
|
)
|
2026-05-27 17:45:52 +00:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-08 09:58:46 +00:00
|
|
|
|
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
|
2026-05-03 16:17:47 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
2026-05-08 09:58:46 +00:00
|
|
|
|
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."
|
2026-05-03 16:17:47 +00:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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}"
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-27 17:45:52 +00:00
|
|
|
|
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}."
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-03 16:17:47 +00:00
|
|
|
|
def build_conversation_messages(
|
|
|
|
|
|
genres: list[str],
|
|
|
|
|
|
setting: list[str],
|
|
|
|
|
|
vibes: list[str],
|
|
|
|
|
|
protagonist: list[str],
|
2026-05-27 17:45:52 +00:00
|
|
|
|
prior_entries_with_choices: list[
|
|
|
|
|
|
tuple[AdventureEntry, list[AdventureEntryPossibleChoice], str | None]
|
|
|
|
|
|
],
|
|
|
|
|
|
max_entry_count: int,
|
|
|
|
|
|
) -> list[GenAiChatMessage]:
|
2026-05-03 16:17:47 +00:00
|
|
|
|
"""Build the full messages array for an Anthropic API call.
|
|
|
|
|
|
|
2026-05-08 09:58:46 +00:00
|
|
|
|
prior_entries is a list of (entry, choices_for_that_entry, selected_choice_id).
|
2026-05-03 16:17:47 +00:00
|
|
|
|
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).
|
|
|
|
|
|
"""
|
2026-05-27 17:45:52 +00:00
|
|
|
|
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
|
|
|
|
|
|
):
|
2026-05-08 09:58:46 +00:00
|
|
|
|
chosen_choice = next((c for c in choices if c.id == selected_choice_id), None)
|
2026-05-27 17:45:52 +00:00
|
|
|
|
|
2026-05-08 09:58:46 +00:00
|
|
|
|
if selected_choice_id is None or chosen_choice is None:
|
2026-05-27 17:45:52 +00:00
|
|
|
|
# We have a problem, no decision was recorded for this entry
|
2026-05-08 09:58:46 +00:00
|
|
|
|
print(f"Warning: no decision found for entry {entry.id}")
|
|
|
|
|
|
continue
|
2026-05-27 17:45:52 +00:00
|
|
|
|
|
2026-05-03 16:17:47 +00:00
|
|
|
|
messages.append(
|
2026-05-27 17:45:52 +00:00
|
|
|
|
GenAiChatMessage(
|
|
|
|
|
|
actor="agent", content=reconstruct_assistant_message(entry, choices)
|
|
|
|
|
|
)
|
2026-05-03 16:17:47 +00:00
|
|
|
|
)
|
2026-05-27 17:45:52 +00:00
|
|
|
|
|
|
|
|
|
|
messages.append(
|
|
|
|
|
|
GenAiChatMessage(
|
|
|
|
|
|
actor="user",
|
|
|
|
|
|
content=reconstruct_choice_message(
|
|
|
|
|
|
chosen_choice.label, index, max_entry_count
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-03 16:17:47 +00:00
|
|
|
|
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
|