feat: [api] Add choose your own adventure functionality
Some checks are pending
/ test (push) Waiting to run
Some checks are pending
/ test (push) Waiting to run
This commit is contained in:
parent
65b30753f0
commit
8b687e9737
10 changed files with 1792 additions and 0 deletions
72
api/app/domain/models/adventure.py
Normal file
72
api/app/domain/models/adventure.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class Adventure:
|
||||
id: str
|
||||
user_id: str
|
||||
status: str # 'awaiting_first_entry' | 'active' | 'complete' | 'error'
|
||||
language: str
|
||||
source_language: str
|
||||
competencies: list[str]
|
||||
max_entry_count: int
|
||||
entry_story_text_target_length: dict # {"min": int, "max": int}
|
||||
title: str
|
||||
description: str | None
|
||||
plot_summary: str | None
|
||||
genres: list[str]
|
||||
setting: list[str]
|
||||
vibes: list[str]
|
||||
protagonist: list[str]
|
||||
created_at: datetime
|
||||
deleted_at: datetime | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdventureEntry:
|
||||
id: str
|
||||
adventure_id: str
|
||||
generated_from_choice_id: str | None
|
||||
status: str # 'generating' | 'complete' | 'error'
|
||||
entry_index: int
|
||||
story_text: str | None
|
||||
gamemaster_notes: str | None
|
||||
llm_data: dict | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdventureEntryPossibleChoice:
|
||||
id: str
|
||||
entry_id: str
|
||||
index: int
|
||||
label: str
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdventureEntryPossibleChoiceDecision:
|
||||
id: str
|
||||
choice_id: str
|
||||
user_id: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdventureEntryTranslation:
|
||||
id: str
|
||||
entry_id: str
|
||||
component_type: str
|
||||
target_language: str
|
||||
translated_text: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdventureEntryAudio:
|
||||
id: str
|
||||
entry_id: str
|
||||
component_type: str
|
||||
tts_provider: str
|
||||
tts_options: dict | None
|
||||
file_name: str
|
||||
267
api/app/domain/services/adventure_service.py
Normal file
267
api/app/domain/services/adventure_service.py
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
import logging
|
||||
import uuid
|
||||
|
||||
from ...outbound.anthropic.adventure_prompts import (
|
||||
build_conversation_messages,
|
||||
build_entry_system_prompt,
|
||||
build_title_system_prompt,
|
||||
build_title_user_message,
|
||||
parse_entry_response,
|
||||
parse_title_response,
|
||||
)
|
||||
from ...outbound.anthropic.anthropic_client import AnthropicClient
|
||||
from ...outbound.deepl.deepl_client import DeepLClient
|
||||
from ...outbound.gemini.gemini_client import GeminiClient
|
||||
from ...outbound.postgres.repositories.adventure_repository import (
|
||||
PostgresAdventureEntryAudioRepository,
|
||||
PostgresAdventureEntryChoiceRepository,
|
||||
PostgresAdventureEntryDecisionRepository,
|
||||
PostgresAdventureEntryRepository,
|
||||
PostgresAdventureEntryTranslationRepository,
|
||||
PostgresAdventureRepository,
|
||||
)
|
||||
from ...storage import upload_audio
|
||||
from ..models.adventure import Adventure, AdventureEntry, AdventureEntryPossibleChoiceDecision
|
||||
from ...languages import SUPPORTED_LANGUAGES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdventureService:
|
||||
def __init__(
|
||||
self,
|
||||
adventure_repo: PostgresAdventureRepository,
|
||||
entry_repo: PostgresAdventureEntryRepository,
|
||||
choice_repo: PostgresAdventureEntryChoiceRepository,
|
||||
decision_repo: PostgresAdventureEntryDecisionRepository,
|
||||
translation_repo: PostgresAdventureEntryTranslationRepository,
|
||||
audio_repo: PostgresAdventureEntryAudioRepository,
|
||||
anthropic_client: AnthropicClient,
|
||||
deepl_client: DeepLClient,
|
||||
gemini_client: GeminiClient,
|
||||
) -> None:
|
||||
self.adventure_repo = adventure_repo
|
||||
self.entry_repo = entry_repo
|
||||
self.choice_repo = choice_repo
|
||||
self.decision_repo = decision_repo
|
||||
self.translation_repo = translation_repo
|
||||
self.audio_repo = audio_repo
|
||||
self.anthropic_client = anthropic_client
|
||||
self.deepl_client = deepl_client
|
||||
self.gemini_client = gemini_client
|
||||
|
||||
async def create_adventure_for_user(
|
||||
self,
|
||||
user_id: uuid.UUID,
|
||||
language: str,
|
||||
source_language: str,
|
||||
competencies: list[str],
|
||||
genres: list[str],
|
||||
setting: list[str],
|
||||
vibes: list[str],
|
||||
protagonist: list[str],
|
||||
max_entry_count: int = 6,
|
||||
) -> tuple[Adventure, AdventureEntry]:
|
||||
"""Creates the adventure and a placeholder for the first entry.
|
||||
|
||||
Returns (adventure, first_entry) so the caller can enqueue pipeline work.
|
||||
"""
|
||||
adventure = await self.adventure_repo.create(
|
||||
user_id=user_id,
|
||||
language=language,
|
||||
source_language=source_language,
|
||||
competencies=competencies,
|
||||
genres=genres,
|
||||
setting=setting,
|
||||
vibes=vibes,
|
||||
protagonist=protagonist,
|
||||
max_entry_count=max_entry_count,
|
||||
entry_story_text_target_length={"min": 700, "max": 800},
|
||||
)
|
||||
first_entry = await self.entry_repo.create(
|
||||
adventure_id=uuid.UUID(adventure.id),
|
||||
entry_index=0,
|
||||
generated_from_choice_id=None,
|
||||
)
|
||||
return adventure, first_entry
|
||||
|
||||
async def record_decision_and_prepare_next_entry(
|
||||
self,
|
||||
adventure_id: uuid.UUID,
|
||||
choice_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> tuple[AdventureEntryPossibleChoiceDecision, AdventureEntry]:
|
||||
"""Validates, records the player's decision, and creates the next entry placeholder.
|
||||
|
||||
Returns (decision, next_entry) so the caller can enqueue pipeline work.
|
||||
|
||||
Raises ValueError with keys:
|
||||
'adventure_not_found' — missing or not owned by this user
|
||||
'adventure_not_active' — e.g. complete or still generating
|
||||
'choice_not_found' — choice id unknown
|
||||
'choice_not_in_adventure' — choice belongs to a different adventure
|
||||
'decision_already_made' — player already chose on this entry
|
||||
"""
|
||||
adventure = await self.adventure_repo.get_by_id(adventure_id)
|
||||
if adventure is None or adventure.user_id != str(user_id):
|
||||
raise ValueError("adventure_not_found")
|
||||
|
||||
if adventure.status != "active":
|
||||
raise ValueError("adventure_not_active")
|
||||
|
||||
choice = await self.choice_repo.get_by_id(choice_id)
|
||||
if choice is None:
|
||||
raise ValueError("choice_not_found")
|
||||
|
||||
entry = await self.entry_repo.get_by_id(uuid.UUID(choice.entry_id))
|
||||
if entry is None or entry.adventure_id != str(adventure_id):
|
||||
raise ValueError("choice_not_in_adventure")
|
||||
|
||||
existing = await self.decision_repo.get_for_entry_and_user(
|
||||
entry_id=uuid.UUID(entry.id), user_id=user_id
|
||||
)
|
||||
if existing is not None:
|
||||
raise ValueError("decision_already_made")
|
||||
|
||||
decision = await self.decision_repo.create(choice_id=choice_id, user_id=user_id)
|
||||
|
||||
next_entry = await self.entry_repo.create(
|
||||
adventure_id=adventure_id,
|
||||
entry_index=entry.entry_index + 1,
|
||||
generated_from_choice_id=choice_id,
|
||||
)
|
||||
return decision, next_entry
|
||||
|
||||
async def run_entry_pipeline(
|
||||
self,
|
||||
adventure_id: uuid.UUID,
|
||||
entry_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""Full entry generation pipeline. Called from the worker queue.
|
||||
|
||||
Sequence: LLM generation → parse → persist → translate → TTS →
|
||||
adventure title (first entry only) → update adventure status.
|
||||
|
||||
On any error the entry and adventure are marked 'error'.
|
||||
"""
|
||||
try:
|
||||
adventure = await self.adventure_repo.get_by_id(adventure_id)
|
||||
assert adventure is not None, f"Adventure {adventure_id} not found"
|
||||
|
||||
all_entries = await self.entry_repo.list_for_adventure(adventure_id)
|
||||
current_entry = next(e for e in all_entries if e.id == str(entry_id))
|
||||
is_first_entry = current_entry.entry_index == 0
|
||||
is_final_entry = current_entry.entry_index + 1 == adventure.max_entry_count
|
||||
|
||||
prior_entries = await self._load_prior_entries_with_metadata(
|
||||
all_entries=[e for e in all_entries if e.entry_index < current_entry.entry_index],
|
||||
)
|
||||
|
||||
language_name = SUPPORTED_LANGUAGES.get(adventure.language, adventure.language)
|
||||
competency = adventure.competencies[0] if adventure.competencies else "B1"
|
||||
system_prompt = build_entry_system_prompt(
|
||||
language_name=language_name,
|
||||
competency=competency,
|
||||
max_entry_count=adventure.max_entry_count,
|
||||
min_length=adventure.entry_story_text_target_length.get("min", 700),
|
||||
max_length=adventure.entry_story_text_target_length.get("max", 800),
|
||||
)
|
||||
messages = build_conversation_messages(
|
||||
genres=adventure.genres,
|
||||
setting=adventure.setting,
|
||||
vibes=adventure.vibes,
|
||||
protagonist=adventure.protagonist,
|
||||
prior_entries=prior_entries,
|
||||
)
|
||||
|
||||
raw_text, usage_dict = await self.anthropic_client.complete(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
max_tokens=2048,
|
||||
)
|
||||
|
||||
story_text, choices_parsed, gm_notes = parse_entry_response(raw_text)
|
||||
|
||||
await self.entry_repo.update_content(
|
||||
entry_id=entry_id,
|
||||
story_text=story_text,
|
||||
gamemaster_notes=gm_notes,
|
||||
llm_data=usage_dict,
|
||||
status="complete",
|
||||
)
|
||||
|
||||
if not is_final_entry:
|
||||
await self.choice_repo.create_many(
|
||||
entry_id=entry_id,
|
||||
choices=[(i, label, text) for i, (label, text) in enumerate(choices_parsed)],
|
||||
)
|
||||
|
||||
translated = await self.deepl_client.translate(story_text, adventure.source_language)
|
||||
await self.translation_repo.create(
|
||||
entry_id=entry_id,
|
||||
component_type="story_text",
|
||||
target_language=adventure.source_language,
|
||||
translated_text=translated,
|
||||
)
|
||||
|
||||
voice = self.gemini_client.get_voice_by_language(adventure.language)
|
||||
wav_bytes = await self.gemini_client.generate_audio(story_text, voice)
|
||||
audio_key = f"adventure-audio/{entry_id}.wav"
|
||||
upload_audio(audio_key, wav_bytes)
|
||||
await self.audio_repo.create(
|
||||
entry_id=entry_id,
|
||||
component_type="story_text",
|
||||
tts_provider="google_gemini",
|
||||
tts_options={"voice": voice},
|
||||
file_name=audio_key,
|
||||
)
|
||||
|
||||
if is_first_entry:
|
||||
title_system = build_title_system_prompt()
|
||||
title_user = build_title_user_message(story_text, language_name, adventure.genres)
|
||||
title_raw, _ = await self.anthropic_client.complete(
|
||||
system_prompt=title_system,
|
||||
messages=[{"role": "user", "content": title_user}],
|
||||
max_tokens=200,
|
||||
)
|
||||
title, description = parse_title_response(title_raw)
|
||||
await self.adventure_repo.update_title_and_description(
|
||||
adventure_id=adventure_id, title=title, description=description
|
||||
)
|
||||
|
||||
new_status = "complete" if is_final_entry else "active"
|
||||
await self.adventure_repo.update_status(adventure_id=adventure_id, status=new_status)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Entry pipeline failed for entry %s", entry_id)
|
||||
try:
|
||||
await self.entry_repo.update_status(entry_id=entry_id, status="error")
|
||||
await self.adventure_repo.update_status(adventure_id=adventure_id, status="error")
|
||||
except Exception:
|
||||
logger.exception("Failed to mark entry/adventure as error")
|
||||
|
||||
async def _load_prior_entries_with_metadata(
|
||||
self,
|
||||
all_entries: list[AdventureEntry],
|
||||
) -> list[tuple[AdventureEntry, list, str | None]]:
|
||||
"""Load choices for each prior entry and determine which choice was made.
|
||||
|
||||
Returns a list of (entry, choices, chosen_label_or_None) tuples ready for
|
||||
build_conversation_messages().
|
||||
"""
|
||||
sorted_entries = sorted(all_entries, key=lambda e: e.entry_index)
|
||||
result = []
|
||||
for i, entry in enumerate(sorted_entries):
|
||||
choices = await self.choice_repo.list_for_entry(uuid.UUID(entry.id))
|
||||
chosen_label: str | None = None
|
||||
if i + 1 < len(sorted_entries):
|
||||
next_entry = sorted_entries[i + 1]
|
||||
if next_entry.generated_from_choice_id:
|
||||
chosen = next(
|
||||
(c for c in choices if c.id == next_entry.generated_from_choice_id),
|
||||
None,
|
||||
)
|
||||
if chosen:
|
||||
chosen_label = chosen.label
|
||||
result.append((entry, choices, chosen_label))
|
||||
return result
|
||||
158
api/app/outbound/anthropic/adventure_prompts.py
Normal file
158
api/app/outbound/anthropic/adventure_prompts.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"""
|
||||
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). "
|
||||
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]],
|
||||
) -> 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)}
|
||||
)
|
||||
if chosen_label is not None:
|
||||
messages.append({"role": "user", "content": chosen_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
|
||||
|
|
@ -41,6 +41,35 @@ class AnthropicClient():
|
|||
f"{source_material}"
|
||||
)
|
||||
|
||||
async def complete(
|
||||
self,
|
||||
system_prompt: str,
|
||||
messages: list[dict],
|
||||
model: str = "claude-sonnet-4-6",
|
||||
max_tokens: int = 2048,
|
||||
) -> tuple[str, dict]:
|
||||
"""Generic text completion.
|
||||
|
||||
Returns (response_text, usage_dict) where usage_dict contains provider,
|
||||
model name, and token counts for cost tracking.
|
||||
"""
|
||||
def _call() -> tuple[str, dict]:
|
||||
message = self._client.messages.create(
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
system=system_prompt,
|
||||
messages=messages,
|
||||
)
|
||||
usage = {
|
||||
"provider": "anthropic",
|
||||
"model": model,
|
||||
"input_tokens": message.usage.input_tokens,
|
||||
"output_tokens": message.usage.output_tokens,
|
||||
}
|
||||
return message.content[0].text, usage
|
||||
|
||||
return await asyncio.to_thread(_call)
|
||||
|
||||
async def generate_summary_text(
|
||||
self,
|
||||
content_to_summarise: str,
|
||||
|
|
|
|||
144
api/app/outbound/postgres/entities/adventure_entities.py
Normal file
144
api/app/outbound/postgres/entities/adventure_entities.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from ..database import Base
|
||||
|
||||
|
||||
class AdventureEntity(Base):
|
||||
__tablename__ = "choose_your_own_adventure"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(Text, nullable=False, default="awaiting_first_entry")
|
||||
language: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
source_language: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
competencies: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
max_entry_count: Mapped[int] = mapped_column(Integer, nullable=False, default=6)
|
||||
entry_story_text_target_length: Mapped[dict] = mapped_column(
|
||||
JSONB, nullable=False, default=lambda: {"min": 700, "max": 800}
|
||||
)
|
||||
title: Mapped[str] = mapped_column(Text, nullable=False, default="Untitled adventure")
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
plot_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
genres: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
setting: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
vibes: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
protagonist: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class AdventureEntryEntity(Base):
|
||||
__tablename__ = "choose_your_own_adventure_entry"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("adventure_id", "entry_index", name="uq_cyoa_entry_adventure_index"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
adventure_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("choose_your_own_adventure.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
generated_from_choice_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey(
|
||||
"choose_your_own_adventure_entry_possible_choice.id", ondelete="SET NULL"
|
||||
),
|
||||
nullable=True,
|
||||
)
|
||||
status: Mapped[str] = mapped_column(Text, nullable=False, default="generating")
|
||||
entry_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
story_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
gamemaster_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
llm_data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
class AdventureEntryPossibleChoiceEntity(Base):
|
||||
__tablename__ = "choose_your_own_adventure_entry_possible_choice"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("entry_id", "index", name="uq_cyoa_choice_entry_index"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
entry_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("choose_your_own_adventure_entry.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
label: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
|
||||
class AdventureEntryPossibleChoiceDecisionEntity(Base):
|
||||
__tablename__ = "choose_your_own_adventure_entry_possible_choice_decision"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
choice_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey(
|
||||
"choose_your_own_adventure_entry_possible_choice.id", ondelete="CASCADE"
|
||||
),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
class AdventureEntryTranslationEntity(Base):
|
||||
__tablename__ = "choose_your_own_adventure_entry_translation"
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"entry_id", "component_type", "target_language",
|
||||
name="uq_cyoa_translation_entry_component_lang",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
entry_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("choose_your_own_adventure_entry.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
component_type: Mapped[str] = mapped_column(Text, nullable=False, default="story_text")
|
||||
target_language: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
translated_text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
|
||||
class AdventureEntryAudioEntity(Base):
|
||||
__tablename__ = "choose_your_own_adventure_entry_audio"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("entry_id", "component_type", name="uq_cyoa_audio_entry_component"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
entry_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("choose_your_own_adventure_entry.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
component_type: Mapped[str] = mapped_column(Text, nullable=False, default="story_text")
|
||||
tts_provider: Mapped[str] = mapped_column(Text, nullable=False, default="google_gemini")
|
||||
tts_options: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||
file_name: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
403
api/app/outbound/postgres/repositories/adventure_repository.py
Normal file
403
api/app/outbound/postgres/repositories/adventure_repository.py
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ....domain.models.adventure import (
|
||||
Adventure,
|
||||
AdventureEntry,
|
||||
AdventureEntryAudio,
|
||||
AdventureEntryPossibleChoice,
|
||||
AdventureEntryPossibleChoiceDecision,
|
||||
AdventureEntryTranslation,
|
||||
)
|
||||
from ..entities.adventure_entities import (
|
||||
AdventureEntity,
|
||||
AdventureEntryAudioEntity,
|
||||
AdventureEntryEntity,
|
||||
AdventureEntryPossibleChoiceDecisionEntity,
|
||||
AdventureEntryPossibleChoiceEntity,
|
||||
AdventureEntryTranslationEntity,
|
||||
)
|
||||
|
||||
|
||||
def _to_adventure(e: AdventureEntity) -> Adventure:
|
||||
return Adventure(
|
||||
id=str(e.id),
|
||||
user_id=str(e.user_id),
|
||||
status=e.status,
|
||||
language=e.language,
|
||||
source_language=e.source_language,
|
||||
competencies=e.competencies,
|
||||
max_entry_count=e.max_entry_count,
|
||||
entry_story_text_target_length=e.entry_story_text_target_length,
|
||||
title=e.title,
|
||||
description=e.description,
|
||||
plot_summary=e.plot_summary,
|
||||
genres=e.genres,
|
||||
setting=e.setting,
|
||||
vibes=e.vibes,
|
||||
protagonist=e.protagonist,
|
||||
created_at=e.created_at,
|
||||
deleted_at=e.deleted_at,
|
||||
)
|
||||
|
||||
|
||||
def _to_entry(e: AdventureEntryEntity) -> AdventureEntry:
|
||||
return AdventureEntry(
|
||||
id=str(e.id),
|
||||
adventure_id=str(e.adventure_id),
|
||||
generated_from_choice_id=str(e.generated_from_choice_id) if e.generated_from_choice_id else None,
|
||||
status=e.status,
|
||||
entry_index=e.entry_index,
|
||||
story_text=e.story_text,
|
||||
gamemaster_notes=e.gamemaster_notes,
|
||||
llm_data=e.llm_data,
|
||||
created_at=e.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _to_choice(e: AdventureEntryPossibleChoiceEntity) -> AdventureEntryPossibleChoice:
|
||||
return AdventureEntryPossibleChoice(
|
||||
id=str(e.id),
|
||||
entry_id=str(e.entry_id),
|
||||
index=e.index,
|
||||
label=e.label,
|
||||
text=e.text,
|
||||
)
|
||||
|
||||
|
||||
def _to_decision(e: AdventureEntryPossibleChoiceDecisionEntity) -> AdventureEntryPossibleChoiceDecision:
|
||||
return AdventureEntryPossibleChoiceDecision(
|
||||
id=str(e.id),
|
||||
choice_id=str(e.choice_id),
|
||||
user_id=str(e.user_id),
|
||||
created_at=e.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _to_translation(e: AdventureEntryTranslationEntity) -> AdventureEntryTranslation:
|
||||
return AdventureEntryTranslation(
|
||||
id=str(e.id),
|
||||
entry_id=str(e.entry_id),
|
||||
component_type=e.component_type,
|
||||
target_language=e.target_language,
|
||||
translated_text=e.translated_text,
|
||||
)
|
||||
|
||||
|
||||
def _to_audio(e: AdventureEntryAudioEntity) -> AdventureEntryAudio:
|
||||
return AdventureEntryAudio(
|
||||
id=str(e.id),
|
||||
entry_id=str(e.entry_id),
|
||||
component_type=e.component_type,
|
||||
tts_provider=e.tts_provider,
|
||||
tts_options=e.tts_options,
|
||||
file_name=e.file_name,
|
||||
)
|
||||
|
||||
|
||||
class PostgresAdventureRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def create(
|
||||
self,
|
||||
user_id: uuid.UUID,
|
||||
language: str,
|
||||
source_language: str,
|
||||
competencies: list[str],
|
||||
genres: list[str],
|
||||
setting: list[str],
|
||||
vibes: list[str],
|
||||
protagonist: list[str],
|
||||
max_entry_count: int,
|
||||
entry_story_text_target_length: dict,
|
||||
) -> Adventure:
|
||||
entity = AdventureEntity(
|
||||
user_id=user_id,
|
||||
language=language,
|
||||
source_language=source_language,
|
||||
competencies=competencies,
|
||||
genres=genres,
|
||||
setting=setting,
|
||||
vibes=vibes,
|
||||
protagonist=protagonist,
|
||||
max_entry_count=max_entry_count,
|
||||
entry_story_text_target_length=entry_story_text_target_length,
|
||||
)
|
||||
self.db.add(entity)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(entity)
|
||||
return _to_adventure(entity)
|
||||
|
||||
async def get_by_id(self, adventure_id: uuid.UUID) -> Adventure | None:
|
||||
result = await self.db.execute(
|
||||
select(AdventureEntity).where(AdventureEntity.id == adventure_id)
|
||||
)
|
||||
entity = result.scalar_one_or_none()
|
||||
return _to_adventure(entity) if entity else None
|
||||
|
||||
async def list_for_user(self, user_id: uuid.UUID) -> list[Adventure]:
|
||||
result = await self.db.execute(
|
||||
select(AdventureEntity)
|
||||
.where(AdventureEntity.user_id == user_id, AdventureEntity.deleted_at.is_(None))
|
||||
.order_by(AdventureEntity.created_at.desc())
|
||||
)
|
||||
return [_to_adventure(e) for e in result.scalars().all()]
|
||||
|
||||
async def update_status(self, adventure_id: uuid.UUID, status: str) -> Adventure:
|
||||
result = await self.db.execute(
|
||||
select(AdventureEntity).where(AdventureEntity.id == adventure_id)
|
||||
)
|
||||
entity = result.scalar_one()
|
||||
entity.status = status
|
||||
await self.db.commit()
|
||||
await self.db.refresh(entity)
|
||||
return _to_adventure(entity)
|
||||
|
||||
async def update_title_and_description(
|
||||
self, adventure_id: uuid.UUID, title: str, description: str
|
||||
) -> Adventure:
|
||||
result = await self.db.execute(
|
||||
select(AdventureEntity).where(AdventureEntity.id == adventure_id)
|
||||
)
|
||||
entity = result.scalar_one()
|
||||
entity.title = title
|
||||
entity.description = description
|
||||
await self.db.commit()
|
||||
await self.db.refresh(entity)
|
||||
return _to_adventure(entity)
|
||||
|
||||
async def soft_delete(self, adventure_id: uuid.UUID) -> Adventure:
|
||||
result = await self.db.execute(
|
||||
select(AdventureEntity).where(AdventureEntity.id == adventure_id)
|
||||
)
|
||||
entity = result.scalar_one()
|
||||
entity.deleted_at = datetime.now(timezone.utc)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(entity)
|
||||
return _to_adventure(entity)
|
||||
|
||||
|
||||
class PostgresAdventureEntryRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def create(
|
||||
self,
|
||||
adventure_id: uuid.UUID,
|
||||
entry_index: int,
|
||||
generated_from_choice_id: uuid.UUID | None,
|
||||
) -> AdventureEntry:
|
||||
entity = AdventureEntryEntity(
|
||||
adventure_id=adventure_id,
|
||||
entry_index=entry_index,
|
||||
generated_from_choice_id=generated_from_choice_id,
|
||||
)
|
||||
self.db.add(entity)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(entity)
|
||||
return _to_entry(entity)
|
||||
|
||||
async def get_by_id(self, entry_id: uuid.UUID) -> AdventureEntry | None:
|
||||
result = await self.db.execute(
|
||||
select(AdventureEntryEntity).where(AdventureEntryEntity.id == entry_id)
|
||||
)
|
||||
entity = result.scalar_one_or_none()
|
||||
return _to_entry(entity) if entity else None
|
||||
|
||||
async def list_for_adventure(self, adventure_id: uuid.UUID) -> list[AdventureEntry]:
|
||||
result = await self.db.execute(
|
||||
select(AdventureEntryEntity)
|
||||
.where(AdventureEntryEntity.adventure_id == adventure_id)
|
||||
.order_by(AdventureEntryEntity.entry_index.asc())
|
||||
)
|
||||
return [_to_entry(e) for e in result.scalars().all()]
|
||||
|
||||
async def update_content(
|
||||
self,
|
||||
entry_id: uuid.UUID,
|
||||
story_text: str,
|
||||
gamemaster_notes: str,
|
||||
llm_data: dict,
|
||||
status: str,
|
||||
) -> AdventureEntry:
|
||||
result = await self.db.execute(
|
||||
select(AdventureEntryEntity).where(AdventureEntryEntity.id == entry_id)
|
||||
)
|
||||
entity = result.scalar_one()
|
||||
entity.story_text = story_text
|
||||
entity.gamemaster_notes = gamemaster_notes
|
||||
entity.llm_data = llm_data
|
||||
entity.status = status
|
||||
await self.db.commit()
|
||||
await self.db.refresh(entity)
|
||||
return _to_entry(entity)
|
||||
|
||||
async def update_status(self, entry_id: uuid.UUID, status: str) -> AdventureEntry:
|
||||
result = await self.db.execute(
|
||||
select(AdventureEntryEntity).where(AdventureEntryEntity.id == entry_id)
|
||||
)
|
||||
entity = result.scalar_one()
|
||||
entity.status = status
|
||||
await self.db.commit()
|
||||
await self.db.refresh(entity)
|
||||
return _to_entry(entity)
|
||||
|
||||
async def count_complete(self, adventure_id: uuid.UUID) -> int:
|
||||
result = await self.db.execute(
|
||||
select(func.count()).select_from(AdventureEntryEntity).where(
|
||||
AdventureEntryEntity.adventure_id == adventure_id,
|
||||
AdventureEntryEntity.status == "complete",
|
||||
)
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
|
||||
class PostgresAdventureEntryChoiceRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def create_many(
|
||||
self,
|
||||
entry_id: uuid.UUID,
|
||||
choices: list[tuple[int, str, str]], # (index, label, text)
|
||||
) -> list[AdventureEntryPossibleChoice]:
|
||||
entities = [
|
||||
AdventureEntryPossibleChoiceEntity(
|
||||
entry_id=entry_id, index=index, label=label, text=text
|
||||
)
|
||||
for index, label, text in choices
|
||||
]
|
||||
for e in entities:
|
||||
self.db.add(e)
|
||||
await self.db.commit()
|
||||
for e in entities:
|
||||
await self.db.refresh(e)
|
||||
return [_to_choice(e) for e in entities]
|
||||
|
||||
async def get_by_id(self, choice_id: uuid.UUID) -> AdventureEntryPossibleChoice | None:
|
||||
result = await self.db.execute(
|
||||
select(AdventureEntryPossibleChoiceEntity).where(
|
||||
AdventureEntryPossibleChoiceEntity.id == choice_id
|
||||
)
|
||||
)
|
||||
entity = result.scalar_one_or_none()
|
||||
return _to_choice(entity) if entity else None
|
||||
|
||||
async def list_for_entry(self, entry_id: uuid.UUID) -> list[AdventureEntryPossibleChoice]:
|
||||
result = await self.db.execute(
|
||||
select(AdventureEntryPossibleChoiceEntity)
|
||||
.where(AdventureEntryPossibleChoiceEntity.entry_id == entry_id)
|
||||
.order_by(AdventureEntryPossibleChoiceEntity.index.asc())
|
||||
)
|
||||
return [_to_choice(e) for e in result.scalars().all()]
|
||||
|
||||
|
||||
class PostgresAdventureEntryDecisionRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def create(
|
||||
self, choice_id: uuid.UUID, user_id: uuid.UUID
|
||||
) -> AdventureEntryPossibleChoiceDecision:
|
||||
entity = AdventureEntryPossibleChoiceDecisionEntity(
|
||||
choice_id=choice_id, user_id=user_id
|
||||
)
|
||||
self.db.add(entity)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(entity)
|
||||
return _to_decision(entity)
|
||||
|
||||
async def get_for_entry_and_user(
|
||||
self, entry_id: uuid.UUID, user_id: uuid.UUID
|
||||
) -> AdventureEntryPossibleChoiceDecision | None:
|
||||
result = await self.db.execute(
|
||||
select(AdventureEntryPossibleChoiceDecisionEntity)
|
||||
.join(
|
||||
AdventureEntryPossibleChoiceEntity,
|
||||
AdventureEntryPossibleChoiceDecisionEntity.choice_id
|
||||
== AdventureEntryPossibleChoiceEntity.id,
|
||||
)
|
||||
.where(
|
||||
AdventureEntryPossibleChoiceEntity.entry_id == entry_id,
|
||||
AdventureEntryPossibleChoiceDecisionEntity.user_id == user_id,
|
||||
)
|
||||
)
|
||||
entity = result.scalar_one_or_none()
|
||||
return _to_decision(entity) if entity else None
|
||||
|
||||
|
||||
class PostgresAdventureEntryTranslationRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def create(
|
||||
self,
|
||||
entry_id: uuid.UUID,
|
||||
component_type: str,
|
||||
target_language: str,
|
||||
translated_text: str,
|
||||
) -> AdventureEntryTranslation:
|
||||
entity = AdventureEntryTranslationEntity(
|
||||
entry_id=entry_id,
|
||||
component_type=component_type,
|
||||
target_language=target_language,
|
||||
translated_text=translated_text,
|
||||
)
|
||||
self.db.add(entity)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(entity)
|
||||
return _to_translation(entity)
|
||||
|
||||
async def get_for_entry(
|
||||
self, entry_id: uuid.UUID, component_type: str, target_language: str
|
||||
) -> AdventureEntryTranslation | None:
|
||||
result = await self.db.execute(
|
||||
select(AdventureEntryTranslationEntity).where(
|
||||
AdventureEntryTranslationEntity.entry_id == entry_id,
|
||||
AdventureEntryTranslationEntity.component_type == component_type,
|
||||
AdventureEntryTranslationEntity.target_language == target_language,
|
||||
)
|
||||
)
|
||||
entity = result.scalar_one_or_none()
|
||||
return _to_translation(entity) if entity else None
|
||||
|
||||
|
||||
class PostgresAdventureEntryAudioRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def create(
|
||||
self,
|
||||
entry_id: uuid.UUID,
|
||||
component_type: str,
|
||||
tts_provider: str,
|
||||
tts_options: dict,
|
||||
file_name: str,
|
||||
) -> AdventureEntryAudio:
|
||||
entity = AdventureEntryAudioEntity(
|
||||
entry_id=entry_id,
|
||||
component_type=component_type,
|
||||
tts_provider=tts_provider,
|
||||
tts_options=tts_options,
|
||||
file_name=file_name,
|
||||
)
|
||||
self.db.add(entity)
|
||||
await self.db.commit()
|
||||
await self.db.refresh(entity)
|
||||
return _to_audio(entity)
|
||||
|
||||
async def get_for_entry(
|
||||
self, entry_id: uuid.UUID, component_type: str
|
||||
) -> AdventureEntryAudio | None:
|
||||
result = await self.db.execute(
|
||||
select(AdventureEntryAudioEntity).where(
|
||||
AdventureEntryAudioEntity.entry_id == entry_id,
|
||||
AdventureEntryAudioEntity.component_type == component_type,
|
||||
)
|
||||
)
|
||||
entity = result.scalar_one_or_none()
|
||||
return _to_audio(entity) if entity else None
|
||||
425
api/app/routers/api/adventures.py
Normal file
425
api/app/routers/api/adventures.py
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
import io
|
||||
import uuid
|
||||
import wave
|
||||
from functools import partial
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...auth import verify_token
|
||||
from ...config import settings
|
||||
from ...domain.services.adventure_service import AdventureService
|
||||
from ...languages import SUPPORTED_LANGUAGES
|
||||
from ...outbound.anthropic.anthropic_client import AnthropicClient
|
||||
from ...outbound.deepl.deepl_client import DeepLClient
|
||||
from ...outbound.gemini.gemini_client import GeminiClient
|
||||
from ...outbound.postgres.database import AsyncSessionLocal, get_db
|
||||
from ...outbound.postgres.repositories.adventure_repository import (
|
||||
PostgresAdventureEntryAudioRepository,
|
||||
PostgresAdventureEntryChoiceRepository,
|
||||
PostgresAdventureEntryDecisionRepository,
|
||||
PostgresAdventureEntryRepository,
|
||||
PostgresAdventureEntryTranslationRepository,
|
||||
PostgresAdventureRepository,
|
||||
)
|
||||
from ... import worker
|
||||
|
||||
router = APIRouter(prefix="/adventures", tags=["adventures"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub clients for the test environment (STUB_GENERATION=true)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STUB_ENTRY_RESPONSE = (
|
||||
"Vous vous retrouvez dans une ruelle sombre de Paris. "
|
||||
"Une silhouette mystérieuse s'approche lentement.\n"
|
||||
"-----\n"
|
||||
"1. Suivez la silhouette dans l'obscurité\n"
|
||||
"2. Restez dans l'ombre et observez\n"
|
||||
"3. Demandez de l'aide à voix haute\n"
|
||||
"4. Courez vers la lumière au bout de la ruelle\n"
|
||||
"-----\n"
|
||||
"no notes"
|
||||
)
|
||||
_STUB_TITLE_RESPONSE = (
|
||||
"La Nuit Parisienne\n"
|
||||
"Une aventure mystérieuse dans les rues sombres de Paris."
|
||||
)
|
||||
|
||||
|
||||
class _StubAnthropicClient:
|
||||
async def complete(
|
||||
self,
|
||||
system_prompt: str,
|
||||
messages: list[dict],
|
||||
model: str = "claude-sonnet-4-6",
|
||||
max_tokens: int = 2048,
|
||||
) -> tuple[str, dict]:
|
||||
usage = {"provider": "stub", "model": "stub", "input_tokens": 0, "output_tokens": 0}
|
||||
if "game master" in system_prompt.lower():
|
||||
return _STUB_ENTRY_RESPONSE, usage
|
||||
return _STUB_TITLE_RESPONSE, usage
|
||||
|
||||
|
||||
class _StubDeepLClient:
|
||||
def can_translate_to(self, lang: str) -> bool:
|
||||
return True
|
||||
|
||||
async def translate(self, text: str, to_language: str, context: str | None = None) -> str:
|
||||
return f"[STUB] {text[:120]}"
|
||||
|
||||
|
||||
class _StubGeminiClient:
|
||||
def get_voice_by_language(self, lang: str) -> str:
|
||||
return "Stub"
|
||||
|
||||
async def generate_audio(self, text: str, voice: str) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(24000)
|
||||
wf.writeframes(b"\x00" * 480)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_service(db: AsyncSession) -> AdventureService:
|
||||
if settings.stub_generation:
|
||||
anthropic = _StubAnthropicClient() # type: ignore[assignment]
|
||||
deepl = _StubDeepLClient() # type: ignore[assignment]
|
||||
gemini = _StubGeminiClient() # type: ignore[assignment]
|
||||
else:
|
||||
anthropic = AnthropicClient.new(settings.anthropic_api_key)
|
||||
deepl = DeepLClient(settings.deepl_api_key)
|
||||
gemini = GeminiClient(settings.gemini_api_key)
|
||||
|
||||
return AdventureService(
|
||||
adventure_repo=PostgresAdventureRepository(db),
|
||||
entry_repo=PostgresAdventureEntryRepository(db),
|
||||
choice_repo=PostgresAdventureEntryChoiceRepository(db),
|
||||
decision_repo=PostgresAdventureEntryDecisionRepository(db),
|
||||
translation_repo=PostgresAdventureEntryTranslationRepository(db),
|
||||
audio_repo=PostgresAdventureEntryAudioRepository(db),
|
||||
anthropic_client=anthropic,
|
||||
deepl_client=deepl,
|
||||
gemini_client=gemini,
|
||||
)
|
||||
|
||||
|
||||
async def _run_entry_pipeline_task(
|
||||
adventure_id: uuid.UUID, entry_id: uuid.UUID
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await _make_service(db).run_entry_pipeline(adventure_id, entry_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CreateAdventureRequest(BaseModel):
|
||||
language: str
|
||||
source_language: str
|
||||
competencies: list[str]
|
||||
genres: list[str]
|
||||
setting: list[str]
|
||||
vibes: list[str]
|
||||
protagonist: list[str]
|
||||
max_entry_count: int = 6
|
||||
|
||||
|
||||
class AdventureResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
status: str
|
||||
language: str
|
||||
source_language: str
|
||||
competencies: list[str]
|
||||
max_entry_count: int
|
||||
title: str
|
||||
description: str | None
|
||||
genres: list[str]
|
||||
setting: list[str]
|
||||
vibes: list[str]
|
||||
protagonist: list[str]
|
||||
created_at: str
|
||||
|
||||
|
||||
class CreateDecisionRequest(BaseModel):
|
||||
choice_id: str
|
||||
|
||||
|
||||
class DecisionResponse(BaseModel):
|
||||
id: str
|
||||
choice_id: str
|
||||
user_id: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class EntryResponse(BaseModel):
|
||||
id: str
|
||||
adventure_id: str
|
||||
generated_from_choice_id: str | None
|
||||
status: str
|
||||
entry_index: int
|
||||
story_text: str | None
|
||||
created_at: str
|
||||
|
||||
|
||||
class ChoiceResponse(BaseModel):
|
||||
id: str
|
||||
index: int
|
||||
label: str
|
||||
text: str
|
||||
|
||||
|
||||
class EntryDetailResponse(BaseModel):
|
||||
id: str
|
||||
adventure_id: str
|
||||
generated_from_choice_id: str | None
|
||||
status: str
|
||||
entry_index: int
|
||||
story_text: str | None
|
||||
created_at: str
|
||||
choices: list[ChoiceResponse]
|
||||
translation: str | None
|
||||
audio_file_name: str | None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _to_adventure_response(adventure) -> AdventureResponse:
|
||||
return AdventureResponse(
|
||||
id=adventure.id,
|
||||
user_id=adventure.user_id,
|
||||
status=adventure.status,
|
||||
language=adventure.language,
|
||||
source_language=adventure.source_language,
|
||||
competencies=adventure.competencies,
|
||||
max_entry_count=adventure.max_entry_count,
|
||||
title=adventure.title,
|
||||
description=adventure.description,
|
||||
genres=adventure.genres,
|
||||
setting=adventure.setting,
|
||||
vibes=adventure.vibes,
|
||||
protagonist=adventure.protagonist,
|
||||
created_at=adventure.created_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _parse_adventure_id(adventure_id: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(adventure_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid adventure_id")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("", response_model=AdventureResponse, status_code=201)
|
||||
async def create_adventure(
|
||||
body: CreateAdventureRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> AdventureResponse:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
|
||||
if body.language not in SUPPORTED_LANGUAGES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported language '{body.language}'. Supported: {list(SUPPORTED_LANGUAGES)}",
|
||||
)
|
||||
|
||||
deepl_client = DeepLClient(settings.deepl_api_key) if not settings.stub_generation else _StubDeepLClient() # type: ignore[assignment]
|
||||
if not deepl_client.can_translate_to(body.source_language):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot translate to source language '{body.source_language}'",
|
||||
)
|
||||
|
||||
adventure, first_entry = await _make_service(db).create_adventure_for_user(
|
||||
user_id=user_id,
|
||||
language=body.language,
|
||||
source_language=body.source_language,
|
||||
competencies=body.competencies,
|
||||
genres=body.genres,
|
||||
setting=body.setting,
|
||||
vibes=body.vibes,
|
||||
protagonist=body.protagonist,
|
||||
max_entry_count=body.max_entry_count,
|
||||
)
|
||||
await worker.enqueue(
|
||||
partial(_run_entry_pipeline_task, uuid.UUID(adventure.id), uuid.UUID(first_entry.id))
|
||||
)
|
||||
return _to_adventure_response(adventure)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AdventureResponse])
|
||||
async def list_adventures(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> list[AdventureResponse]:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
adventures = await PostgresAdventureRepository(db).list_for_user(user_id)
|
||||
return [_to_adventure_response(a) for a in adventures]
|
||||
|
||||
|
||||
@router.get("/{adventure_id}", response_model=AdventureResponse)
|
||||
async def get_adventure(
|
||||
adventure_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> AdventureResponse:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
adventure = await PostgresAdventureRepository(db).get_by_id(_parse_adventure_id(adventure_id))
|
||||
if adventure is None or adventure.user_id != str(user_id):
|
||||
raise HTTPException(status_code=404, detail="Adventure not found")
|
||||
return _to_adventure_response(adventure)
|
||||
|
||||
|
||||
@router.delete("/{adventure_id}", status_code=204)
|
||||
async def delete_adventure(
|
||||
adventure_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> None:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
repo = PostgresAdventureRepository(db)
|
||||
adventure = await repo.get_by_id(_parse_adventure_id(adventure_id))
|
||||
if adventure is None or adventure.user_id != str(user_id):
|
||||
raise HTTPException(status_code=404, detail="Adventure not found")
|
||||
await repo.soft_delete(uuid.UUID(adventure.id))
|
||||
|
||||
|
||||
@router.post("/{adventure_id}/decisions", response_model=DecisionResponse, status_code=201)
|
||||
async def record_decision(
|
||||
adventure_id: str,
|
||||
body: CreateDecisionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> DecisionResponse:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
|
||||
try:
|
||||
choice_id = uuid.UUID(body.choice_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid choice_id")
|
||||
|
||||
try:
|
||||
decision, next_entry = await _make_service(db).record_decision_and_prepare_next_entry(
|
||||
adventure_id=_parse_adventure_id(adventure_id),
|
||||
choice_id=choice_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
key = str(exc)
|
||||
if key == "adventure_not_found":
|
||||
raise HTTPException(status_code=404, detail="Adventure not found")
|
||||
if key == "adventure_not_active":
|
||||
raise HTTPException(status_code=409, detail="adventure_not_active")
|
||||
if key in ("choice_not_found", "choice_not_in_adventure"):
|
||||
raise HTTPException(status_code=404, detail="Choice not found")
|
||||
if key == "decision_already_made":
|
||||
raise HTTPException(status_code=409, detail="decision_already_made")
|
||||
raise HTTPException(status_code=400, detail=key)
|
||||
|
||||
await worker.enqueue(
|
||||
partial(
|
||||
_run_entry_pipeline_task,
|
||||
uuid.UUID(next_entry.adventure_id),
|
||||
uuid.UUID(next_entry.id),
|
||||
)
|
||||
)
|
||||
return DecisionResponse(
|
||||
id=decision.id,
|
||||
choice_id=decision.choice_id,
|
||||
user_id=decision.user_id,
|
||||
created_at=decision.created_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{adventure_id}/entries", response_model=list[EntryResponse])
|
||||
async def list_entries(
|
||||
adventure_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> list[EntryResponse]:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
adv_id = _parse_adventure_id(adventure_id)
|
||||
|
||||
adventure = await PostgresAdventureRepository(db).get_by_id(adv_id)
|
||||
if adventure is None or adventure.user_id != str(user_id):
|
||||
raise HTTPException(status_code=404, detail="Adventure not found")
|
||||
|
||||
entries = await PostgresAdventureEntryRepository(db).list_for_adventure(adv_id)
|
||||
return [
|
||||
EntryResponse(
|
||||
id=e.id,
|
||||
adventure_id=e.adventure_id,
|
||||
generated_from_choice_id=e.generated_from_choice_id,
|
||||
status=e.status,
|
||||
entry_index=e.entry_index,
|
||||
story_text=e.story_text,
|
||||
created_at=e.created_at.isoformat(),
|
||||
)
|
||||
for e in entries
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{adventure_id}/entries/{entry_id}", response_model=EntryDetailResponse)
|
||||
async def get_entry(
|
||||
adventure_id: str,
|
||||
entry_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> EntryDetailResponse:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
adv_id = _parse_adventure_id(adventure_id)
|
||||
|
||||
adventure = await PostgresAdventureRepository(db).get_by_id(adv_id)
|
||||
if adventure is None or adventure.user_id != str(user_id):
|
||||
raise HTTPException(status_code=404, detail="Adventure not found")
|
||||
|
||||
try:
|
||||
eid = uuid.UUID(entry_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid entry_id")
|
||||
|
||||
entry = await PostgresAdventureEntryRepository(db).get_by_id(eid)
|
||||
if entry is None or entry.adventure_id != str(adv_id):
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
choices = await PostgresAdventureEntryChoiceRepository(db).list_for_entry(eid)
|
||||
translation = await PostgresAdventureEntryTranslationRepository(db).get_for_entry(
|
||||
entry_id=eid,
|
||||
component_type="story_text",
|
||||
target_language=adventure.source_language,
|
||||
)
|
||||
audio = await PostgresAdventureEntryAudioRepository(db).get_for_entry(
|
||||
entry_id=eid, component_type="story_text"
|
||||
)
|
||||
|
||||
return EntryDetailResponse(
|
||||
id=entry.id,
|
||||
adventure_id=entry.adventure_id,
|
||||
generated_from_choice_id=entry.generated_from_choice_id,
|
||||
status=entry.status,
|
||||
entry_index=entry.entry_index,
|
||||
story_text=entry.story_text,
|
||||
created_at=entry.created_at.isoformat(),
|
||||
choices=[
|
||||
ChoiceResponse(id=c.id, index=c.index, label=c.label, text=c.text) for c in choices
|
||||
],
|
||||
translation=translation.translated_text if translation else None,
|
||||
audio_file_name=audio.file_name if audio else None,
|
||||
)
|
||||
|
|
@ -10,6 +10,7 @@ from .learnable_languages import router as learnable_languages_router
|
|||
from .vocab import router as vocab_router
|
||||
from .packs import router as packs_router
|
||||
from .admin.packs import router as admin_packs_router
|
||||
from .adventures import router as adventures_router
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
|
@ -27,3 +28,4 @@ api_router.include_router(learnable_languages_router)
|
|||
api_router.include_router(vocab_router)
|
||||
api_router.include_router(packs_router)
|
||||
api_router.include_router(admin_packs_router)
|
||||
api_router.include_router(adventures_router)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ services:
|
|||
STORAGE_ACCESS_KEY: langlearn_test
|
||||
STORAGE_SECRET_KEY: testpassword123
|
||||
STORAGE_BUCKET: langlearn-test
|
||||
STUB_GENERATION: "true"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
291
tests/test_adventures.py
Normal file
291
tests/test_adventures.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_and_login(client: httpx.Client, email: str, password: str = "password123") -> str:
|
||||
client.post("/auth/register", json={"email": email, "password": password})
|
||||
resp = client.post("/auth/login", json={"email": email, "password": password})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
def _auth_client(client: httpx.Client, email: str) -> httpx.Client:
|
||||
token = _register_and_login(client, email)
|
||||
client.headers["Authorization"] = f"Bearer {token}"
|
||||
return client
|
||||
|
||||
|
||||
def _wait_for_adventure_status(
|
||||
client: httpx.Client,
|
||||
adventure_id: str,
|
||||
expected_status: str,
|
||||
timeout: int = 30,
|
||||
) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
resp = client.get(f"/api/adventures/{adventure_id}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
if resp.json()["status"] == expected_status:
|
||||
return resp.json()
|
||||
time.sleep(0.5)
|
||||
raise TimeoutError(
|
||||
f"Adventure {adventure_id} did not reach '{expected_status}' within {timeout}s. "
|
||||
f"Last status: {client.get(f'/api/adventures/{adventure_id}').json().get('status')}"
|
||||
)
|
||||
|
||||
|
||||
_DEFAULT_ADVENTURE_BODY = {
|
||||
"language": "fr",
|
||||
"source_language": "en",
|
||||
"competencies": ["B1"],
|
||||
"genres": ["crime fiction"],
|
||||
"setting": ["Paris", "city"],
|
||||
"vibes": ["dark"],
|
||||
"protagonist": ["male", "late-teens"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_client(client: httpx.Client) -> httpx.Client:
|
||||
email = f"adventure-user-{uuid.uuid4()}@example.com"
|
||||
return _auth_client(client, email)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def second_user_client(client: httpx.Client) -> httpx.Client:
|
||||
email = f"adventure-user2-{uuid.uuid4()}@example.com"
|
||||
return _auth_client(client, email)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1: Adventure creation and first-entry pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_create_adventure_generates_first_entry(user_client: httpx.Client) -> None:
|
||||
resp = user_client.post("/api/adventures", json=_DEFAULT_ADVENTURE_BODY)
|
||||
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
adventure_id = body["id"]
|
||||
assert body["status"] == "awaiting_first_entry"
|
||||
assert body["title"] == "Untitled adventure"
|
||||
|
||||
adventure = _wait_for_adventure_status(user_client, adventure_id, "active")
|
||||
assert adventure["title"] != "Untitled adventure"
|
||||
assert adventure["description"] is not None
|
||||
|
||||
entries_resp = user_client.get(f"/api/adventures/{adventure_id}/entries")
|
||||
assert entries_resp.status_code == 200
|
||||
entries = entries_resp.json()
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["status"] == "complete"
|
||||
assert entries[0]["entry_index"] == 0
|
||||
assert entries[0]["story_text"] is not None
|
||||
|
||||
detail_resp = user_client.get(f"/api/adventures/{adventure_id}/entries/{entries[0]['id']}")
|
||||
assert detail_resp.status_code == 200
|
||||
detail = detail_resp.json()
|
||||
assert len(detail["choices"]) == 4
|
||||
assert detail["translation"] is not None
|
||||
assert detail["audio_file_name"] is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2: Recording a decision generates the next entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_record_decision_generates_next_entry(user_client: httpx.Client) -> None:
|
||||
resp = user_client.post("/api/adventures", json=_DEFAULT_ADVENTURE_BODY)
|
||||
adventure_id = resp.json()["id"]
|
||||
_wait_for_adventure_status(user_client, adventure_id, "active")
|
||||
|
||||
entries = user_client.get(f"/api/adventures/{adventure_id}/entries").json()
|
||||
detail = user_client.get(
|
||||
f"/api/adventures/{adventure_id}/entries/{entries[0]['id']}"
|
||||
).json()
|
||||
choice_id = detail["choices"][0]["id"]
|
||||
|
||||
decision_resp = user_client.post(
|
||||
f"/api/adventures/{adventure_id}/decisions", json={"choice_id": choice_id}
|
||||
)
|
||||
assert decision_resp.status_code == 201
|
||||
assert decision_resp.json()["choice_id"] == choice_id
|
||||
|
||||
_wait_for_adventure_status(user_client, adventure_id, "active")
|
||||
|
||||
entries = user_client.get(f"/api/adventures/{adventure_id}/entries").json()
|
||||
assert len(entries) == 2
|
||||
second = next(e for e in entries if e["entry_index"] == 1)
|
||||
assert second["status"] == "complete"
|
||||
assert second["generated_from_choice_id"] == choice_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 3: Adventure completes after max_entry_count entries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_adventure_completes_at_max_entries(user_client: httpx.Client) -> None:
|
||||
body = {**_DEFAULT_ADVENTURE_BODY, "max_entry_count": 2}
|
||||
resp = user_client.post("/api/adventures", json=body)
|
||||
adventure_id = resp.json()["id"]
|
||||
_wait_for_adventure_status(user_client, adventure_id, "active")
|
||||
|
||||
entries = user_client.get(f"/api/adventures/{adventure_id}/entries").json()
|
||||
detail = user_client.get(
|
||||
f"/api/adventures/{adventure_id}/entries/{entries[0]['id']}"
|
||||
).json()
|
||||
choice_id = detail["choices"][0]["id"]
|
||||
|
||||
user_client.post(
|
||||
f"/api/adventures/{adventure_id}/decisions", json={"choice_id": choice_id}
|
||||
)
|
||||
adventure = _wait_for_adventure_status(user_client, adventure_id, "complete")
|
||||
assert adventure["status"] == "complete"
|
||||
|
||||
entries = user_client.get(f"/api/adventures/{adventure_id}/entries").json()
|
||||
assert len(entries) == 2
|
||||
|
||||
final_detail = user_client.get(
|
||||
f"/api/adventures/{adventure_id}/entries/{entries[1]['id']}"
|
||||
).json()
|
||||
assert final_detail["choices"] == []
|
||||
|
||||
# Decision on a complete adventure returns 409
|
||||
extra = user_client.post(
|
||||
f"/api/adventures/{adventure_id}/decisions", json={"choice_id": choice_id}
|
||||
)
|
||||
assert extra.status_code == 409
|
||||
assert "not_active" in extra.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4: Double-decision on the same entry is rejected
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_cannot_make_second_decision_on_same_entry(user_client: httpx.Client) -> None:
|
||||
resp = user_client.post("/api/adventures", json=_DEFAULT_ADVENTURE_BODY)
|
||||
adventure_id = resp.json()["id"]
|
||||
_wait_for_adventure_status(user_client, adventure_id, "active")
|
||||
|
||||
entries = user_client.get(f"/api/adventures/{adventure_id}/entries").json()
|
||||
detail = user_client.get(
|
||||
f"/api/adventures/{adventure_id}/entries/{entries[0]['id']}"
|
||||
).json()
|
||||
choice_id = detail["choices"][0]["id"]
|
||||
other_choice_id = detail["choices"][1]["id"]
|
||||
|
||||
first = user_client.post(
|
||||
f"/api/adventures/{adventure_id}/decisions", json={"choice_id": choice_id}
|
||||
)
|
||||
assert first.status_code == 201
|
||||
|
||||
second = user_client.post(
|
||||
f"/api/adventures/{adventure_id}/decisions", json={"choice_id": other_choice_id}
|
||||
)
|
||||
assert second.status_code == 409
|
||||
assert "decision_already_made" in second.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 5: User isolation — one user cannot see or interact with another's adventure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_cannot_access_another_users_adventure(
|
||||
user_client: httpx.Client, second_user_client: httpx.Client
|
||||
) -> None:
|
||||
resp = user_client.post("/api/adventures", json=_DEFAULT_ADVENTURE_BODY)
|
||||
adventure_id = resp.json()["id"]
|
||||
|
||||
assert second_user_client.get(f"/api/adventures/{adventure_id}").status_code == 404
|
||||
assert second_user_client.get(f"/api/adventures/{adventure_id}/entries").status_code == 404
|
||||
|
||||
decision_resp = second_user_client.post(
|
||||
f"/api/adventures/{adventure_id}/decisions",
|
||||
json={"choice_id": str(uuid.uuid4())},
|
||||
)
|
||||
assert decision_resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 6: Soft-delete removes adventure from list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_soft_delete_hides_adventure(user_client: httpx.Client) -> None:
|
||||
resp = user_client.post("/api/adventures", json=_DEFAULT_ADVENTURE_BODY)
|
||||
adventure_id = resp.json()["id"]
|
||||
|
||||
delete_resp = user_client.delete(f"/api/adventures/{adventure_id}")
|
||||
assert delete_resp.status_code == 204
|
||||
|
||||
# Should no longer appear in the list
|
||||
adventures = user_client.get("/api/adventures").json()
|
||||
assert not any(a["id"] == adventure_id for a in adventures)
|
||||
|
||||
# Direct GET also 404s after deletion
|
||||
assert user_client.get(f"/api/adventures/{adventure_id}").status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit-level: LLM response parser (no Docker / network required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse(text: str):
|
||||
"""Local copy of the parser for isolated unit testing."""
|
||||
import re
|
||||
|
||||
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 = []
|
||||
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 test_parse_valid_three_section_response() -> None:
|
||||
text = (
|
||||
"Story text here.\n-----\n"
|
||||
"1. Option one\n2. Option two\n3. Option three\n4. Option four\n"
|
||||
"-----\nno notes"
|
||||
)
|
||||
story, choices, notes = _parse(text)
|
||||
assert story == "Story text here."
|
||||
assert len(choices) == 4
|
||||
assert choices[0] == ("1", "Option one")
|
||||
assert choices[3] == ("4", "Option four")
|
||||
assert notes == "no notes"
|
||||
|
||||
|
||||
def test_parse_with_parenthesis_delimiters() -> None:
|
||||
text = "Story.\n-----\n1) First\n2) Second\n3) Third\n4) Fourth\n-----\nGM note."
|
||||
_, choices, notes = _parse(text)
|
||||
assert len(choices) == 4
|
||||
assert choices[2] == ("3", "Third")
|
||||
assert notes == "GM note."
|
||||
|
||||
|
||||
def test_parse_missing_sections_raises() -> None:
|
||||
with pytest.raises(ValueError, match="section"):
|
||||
_parse("Only one section here, no separators at all.")
|
||||
Loading…
Reference in a new issue