language-learning-app/api/docs/technical-doc-choose-your-own-adventure.md

1654 lines
59 KiB
Markdown
Raw Normal View History

# Technical Document: Choose Your Own Adventure
**REVIEW FEEDBACK**: I have read my waythrough the document, and the bones are good. The suggested document centralised the code, and created monster/mamoth code. I have left feedback suggesting where I think we can split it out. It also advised the buildin of BFF endpoints, but until we design the UI we can't suggest that, so I have removed all reference. In general, let's push for a bit more separation between our layers, especially the way LLMs are communicated with over HTTP - the code that makes the call, the code that generates the prompts, and the code that requires the LLM result to do domain-y things are all separate roles, and would all change for different reasons. Let's not get too philosophical or atomic, let's stay pragmatic - but having massive methods or loads of methods on a class was definitely a code smell.
This document translates the [design doc](./design-doc-choose-your-own-adventure.md) into a concrete, actionable set of changes for the API. It is intended to be handed directly to an LLM for implementation.
---
## Summary
The Choose Your Own Adventure (CYOA) feature generates interactive story content using Claude. A learner creates an adventure with creative parameters (genre, setting, vibes, protagonist). The API generates the first story entry via the Anthropic API, then translates it via DeepL and produces TTS audio via Gemini. The learner reads the entry and chooses from four options at the end. Each choice triggers the next entry generation. Adventures run for a configurable number of entries (default 6).
All LLM/translation/TTS work runs through the existing in-process worker queue (`app/worker.py`).
---
## State Machines
### Adventure.status
```
'awaiting_first_entry'
→ first entry pipeline completes successfully → 'active'
→ first entry pipeline errors → 'error'
'active'
→ decision recorded AND entry_count reaches max_entry_count → 'complete'
→ subsequent entry pipeline errors → 'error'
'complete' (terminal)
'error' (terminal — user can see it errored; no auto-retry in MVP)
```
### AdventureEntry.status
```
'generating'
→ pipeline completes (story_text set, choices saved, translation saved, audio saved) → 'complete'
→ any pipeline step throws an unhandled exception → 'error'
```
---
## Generation Pipeline
The pipeline runs inside a worker task (enqueued via `app.worker.enqueue`). The worker processes tasks serially.
### First entry (entry_index = 0)
1. Create `AdventureEntry` row with `status='generating'`, `entry_index=0`, `generated_from_choice_id=None`
2. Build system prompt (see §AnthropicClient)
3. Build initial user message from adventure parameters
4. Call `AnthropicClient.generate_adventure_entry(...)` — returns `(raw_text, usage_dict)`
5. Parse raw_text into `(story_text, choices_raw, gamemaster_notes)` using `parse_llm_response()`
6. Parse `choices_raw` into list of `(label, text)` pairs
7. Update entry: set `story_text`, `gamemaster_notes`, `llm_data`, `status='complete'`
8. Create `AdventureEntryPossibleChoice` rows (one per option, typically 4)
9. Call `DeepLClient.translate(story_text, source_language)` → create `AdventureEntryTranslation`
10. Call `GeminiClient.generate_audio(story_text, voice)` → upload to S3 → create `AdventureEntryAudio`
11. Call `AnthropicClient.generate_adventure_title_and_description(story_text, ...)` → returns `(title, description)`
12. Update adventure: set `title`, `description`, `status='active'`
On any exception during steps 212: set `entry.status='error'` and `adventure.status='error'`.
### Subsequent entries (entry_index > 0)
Same pipeline except:
- `generated_from_choice_id` is set to the chosen `AdventureEntryPossibleChoice.id`
- Step 3: conversation history is passed to `generate_adventure_entry` (see §Conversation History)
- If this entry completes AND `entry_index + 1 == adventure.max_entry_count`: set `adventure.status='complete'`; no choices are created for the final entry
- Step 1112 (title/description) is skipped — already set
---
## Conversation History
Each call to `AnthropicClient.generate_adventure_entry` (after the first) must include the full conversation history so the model maintains narrative continuity. Reconstruct it from the stored entries:
```
messages = [
{"role": "user", "content": <initial_setup_message>}, # constructed from adventure params
{"role": "assistant", "content": <reconstructed_entry_0_response>},
{"role": "user", "content": "<label of chosen option>"}, # e.g. "2"
{"role": "assistant", "content": <reconstructed_entry_1_response>},
{"role": "user", "content": "<label of chosen option>"},
...
]
```
Reconstruct an entry's assistant message by re-joining stored fields:
```python
def _reconstruct_entry_response(entry: AdventureEntry, choices: list[AdventureEntryPossibleChoice]) -> str:
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}"
```
The label of the chosen option comes from looking up the `AdventureEntryPossibleChoice` referenced by `generated_from_choice_id` of the *next* entry.
---
## LLM Response Parsing
The LLM is instructed to return three sections separated by `\n-----\n`. A parser function lives in the service (or a helper module):
```python
def parse_llm_response(text: str) -> tuple[str, list[tuple[str, str]], str]:
"""Returns (story_text, choices, gm_notes).
choices is a list of (label, text) e.g. [("1", "Go into the house"), ...]
Raises ValueError if format is invalid.
"""
parts = text.split("\n-----\n")
if len(parts) < 3:
# Try relaxed split as fallback
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 = parts[2].strip()
choices: list[tuple[str, str]] = []
for line in options_raw.splitlines():
line = line.strip()
if not line:
continue
# Match "1. Text" or "1) Text"
import re
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")
return story_text, choices, gm_notes
```
---
## New Files
```
app/domain/models/adventure.py
app/domain/services/adventure_service.py
app/routers/api/adventures.py
app/outbound/postgres/entities/adventure_entities.py
app/outbound/postgres/repositories/adventure_repository.py
alembic/versions/20260503_0016_add_choose_your_own_adventure.py
tests/test_adventures.py
```
Modified files:
```
app/outbound/anthropic/anthropic_client.py (add 2 methods)
app/routers/api/main.py (register router)
```
---
## Database Migration
File: `alembic/versions/20260503_0016_add_choose_your_own_adventure.py`
The `choose_your_own_adventure_entry` and `choose_your_own_adventure_entry_possible_choice` tables have a circular FK relationship:
- `entry.generated_from_choice_id → possible_choice.id`
- `possible_choice.entry_id → entry.id`
Resolve this by creating both tables without the circular FK, then adding it with `ALTER TABLE`.
```python
"""add choose_your_own_adventure tables
Revision ID: 0016
Revises: 0015
Create Date: 2026-05-03
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
def upgrade() -> None:
# 1. Adventure header
op.create_table(
"choose_your_own_adventure",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("status", sa.Text(), nullable=False, server_default="awaiting_first_entry"),
sa.Column("language", sa.Text(), nullable=False),
sa.Column("source_language", sa.Text(), nullable=False),
sa.Column("competencies", postgresql.JSONB(), nullable=False, server_default="[]"),
sa.Column("max_entry_count", sa.Integer(), nullable=False, server_default="6"),
sa.Column(
"entry_story_text_target_length",
postgresql.JSONB(),
nullable=False,
server_default='{"min": 700, "max": 800}',
),
sa.Column("title", sa.Text(), nullable=False, server_default="Untitled adventure"),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("plot_summary", sa.Text(), nullable=True),
sa.Column("genres", postgresql.JSONB(), nullable=False, server_default="[]"),
sa.Column("setting", postgresql.JSONB(), nullable=False, server_default="[]"),
sa.Column("vibes", postgresql.JSONB(), nullable=False, server_default="[]"),
sa.Column("protagonist", postgresql.JSONB(), nullable=False, server_default="[]"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_cyoa_user_id", "choose_your_own_adventure", ["user_id"])
op.create_index("ix_cyoa_status", "choose_your_own_adventure", ["status"])
# 2. Entry table — created WITHOUT the circular FK to possible_choice (added below)
op.create_table(
"choose_your_own_adventure_entry",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"adventure_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("choose_your_own_adventure.id", ondelete="CASCADE"),
nullable=False,
),
# generated_from_choice_id FK added after possible_choice table is created
sa.Column("generated_from_choice_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column("status", sa.Text(), nullable=False, server_default="generating"),
sa.Column("entry_index", sa.Integer(), nullable=False),
sa.Column("story_text", sa.Text(), nullable=True),
sa.Column("gamemaster_notes", sa.Text(), nullable=True),
sa.Column("llm_data", postgresql.JSONB(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
sa.UniqueConstraint("adventure_id", "entry_index", name="uq_cyoa_entry_adventure_index"),
)
op.create_index("ix_cyoa_entry_adventure_id", "choose_your_own_adventure_entry", ["adventure_id"])
# 3. Possible choices for each entry
op.create_table(
"choose_your_own_adventure_entry_possible_choice",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"entry_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("choose_your_own_adventure_entry.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("index", sa.Integer(), nullable=False),
sa.Column("label", sa.Text(), nullable=False),
sa.Column("text", sa.Text(), nullable=False),
sa.UniqueConstraint("entry_id", "index", name="uq_cyoa_choice_entry_index"),
)
op.create_index(
"ix_cyoa_choice_entry_id",
"choose_your_own_adventure_entry_possible_choice",
["entry_id"],
)
# 4. Now add the circular FK from entry → possible_choice
op.create_foreign_key(
"fk_cyoa_entry_generated_from_choice",
"choose_your_own_adventure_entry",
"choose_your_own_adventure_entry_possible_choice",
["generated_from_choice_id"],
["id"],
ondelete="SET NULL",
)
# 5. Decision: which choice the user made
op.create_table(
"choose_your_own_adventure_entry_possible_choice_decision",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"choice_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey(
"choose_your_own_adventure_entry_possible_choice.id", ondelete="CASCADE"
),
nullable=False,
),
sa.Column(
"user_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
op.create_index(
"ix_cyoa_decision_choice_id",
"choose_your_own_adventure_entry_possible_choice_decision",
["choice_id"],
)
op.create_index(
"ix_cyoa_decision_user_id",
"choose_your_own_adventure_entry_possible_choice_decision",
["user_id"],
)
# 6. Translation of entry story_text
op.create_table(
"choose_your_own_adventure_entry_translation",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"entry_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("choose_your_own_adventure_entry.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("component_type", sa.Text(), nullable=False, server_default="story_text"),
sa.Column("target_language", sa.Text(), nullable=False),
sa.Column("translated_text", sa.Text(), nullable=False),
sa.UniqueConstraint(
"entry_id", "component_type", "target_language",
name="uq_cyoa_translation_entry_component_lang",
),
)
op.create_index(
"ix_cyoa_translation_entry_id",
"choose_your_own_adventure_entry_translation",
["entry_id"],
)
# 7. TTS audio for entry story_text
op.create_table(
"choose_your_own_adventure_entry_audio",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"entry_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("choose_your_own_adventure_entry.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("component_type", sa.Text(), nullable=False, server_default="story_text"),
sa.Column("tts_provider", sa.Text(), nullable=False, server_default="google_gemini"),
sa.Column("tts_options", postgresql.JSONB(), nullable=True),
sa.Column("file_name", sa.Text(), nullable=False),
sa.UniqueConstraint(
"entry_id", "component_type", name="uq_cyoa_audio_entry_component"
),
)
op.create_index(
"ix_cyoa_audio_entry_id",
"choose_your_own_adventure_entry_audio",
["entry_id"],
)
def downgrade() -> None:
op.drop_table("choose_your_own_adventure_entry_audio")
op.drop_table("choose_your_own_adventure_entry_translation")
op.drop_table("choose_your_own_adventure_entry_possible_choice_decision")
op.drop_constraint(
"fk_cyoa_entry_generated_from_choice", "choose_your_own_adventure_entry", type_="foreignkey"
)
op.drop_table("choose_your_own_adventure_entry_possible_choice")
op.drop_table("choose_your_own_adventure_entry")
op.drop_table("choose_your_own_adventure")
```
---
## ORM Entities
File: `app/outbound/postgres/entities/adventure_entities.py`
```python
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, 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)
```
---
## Domain Models
File: `app/domain/models/adventure.py`
```python
from dataclasses import dataclass, field
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 # {"provider": "anthropic", "model": "...", "input_tokens": int, "output_tokens": int}
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 # 'story_text'
target_language: str
translated_text: str
@dataclass
class AdventureEntryAudio:
id: str
entry_id: str
component_type: str # 'story_text'
tts_provider: str
tts_options: dict | None
file_name: str
```
---
## Repository Layer
**REVIEW FEEDBACK**: Having a single repository that handle multiple tables gives the repository too many things to do. Break the below suggestions down into a set of smaller repositories, one repository per table.
File: `app/outbound/postgres/repositories/adventure_repository.py`
### Protocol
```python
from typing import Protocol
import uuid
from ....domain.models.adventure import (
Adventure,
AdventureEntry,
AdventureEntryAudio,
AdventureEntryPossibleChoice,
AdventureEntryPossibleChoiceDecision,
AdventureEntryTranslation,
)
class AdventureRepository(Protocol):
async def create_adventure(
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: ...
async def get_adventure_by_id(self, adventure_id: uuid.UUID) -> Adventure | None: ...
async def list_adventures_for_user(self, user_id: uuid.UUID) -> list[Adventure]: ...
async def update_adventure_status(
self, adventure_id: uuid.UUID, status: str
) -> Adventure: ...
async def update_adventure_title_and_description(
self, adventure_id: uuid.UUID, title: str, description: str
) -> Adventure: ...
async def soft_delete_adventure(self, adventure_id: uuid.UUID) -> Adventure: ...
async def create_entry(
self,
adventure_id: uuid.UUID,
entry_index: int,
generated_from_choice_id: uuid.UUID | None,
) -> AdventureEntry: ...
async def update_entry_content(
self,
entry_id: uuid.UUID,
story_text: str,
gamemaster_notes: str,
llm_data: dict,
status: str,
) -> AdventureEntry: ...
async def update_entry_status(
self, entry_id: uuid.UUID, status: str
) -> AdventureEntry: ...
async def get_entry_by_id(self, entry_id: uuid.UUID) -> AdventureEntry | None: ...
async def list_entries_for_adventure(
self, adventure_id: uuid.UUID
) -> list[AdventureEntry]: ...
async def create_choices(
self,
entry_id: uuid.UUID,
choices: list[tuple[int, str, str]], # (index, label, text)
) -> list[AdventureEntryPossibleChoice]: ...
async def get_choices_for_entry(
self, entry_id: uuid.UUID
) -> list[AdventureEntryPossibleChoice]: ...
async def get_choice_by_id(
self, choice_id: uuid.UUID
) -> AdventureEntryPossibleChoice | None: ...
async def create_decision(
self, choice_id: uuid.UUID, user_id: uuid.UUID
) -> AdventureEntryPossibleChoiceDecision: ...
async def get_decision_for_entry(
self, adventure_id: uuid.UUID, entry_id: uuid.UUID
) -> AdventureEntryPossibleChoiceDecision | None: ...
async def create_translation(
self,
entry_id: uuid.UUID,
component_type: str,
target_language: str,
translated_text: str,
) -> AdventureEntryTranslation: ...
async def get_translation_for_entry(
self, entry_id: uuid.UUID, component_type: str, target_language: str
) -> AdventureEntryTranslation | None: ...
async def create_audio(
self,
entry_id: uuid.UUID,
component_type: str,
tts_provider: str,
tts_options: dict,
file_name: str,
) -> AdventureEntryAudio: ...
async def get_audio_for_entry(
self, entry_id: uuid.UUID, component_type: str
) -> AdventureEntryAudio | None: ...
async def count_complete_entries(self, adventure_id: uuid.UUID) -> int: ...
```
### Implementation: `PostgresAdventureRepository`
Follow the exact same pattern as `PostgresVocabRepository`:
- Private `_to_adventure(entity)`, `_to_entry(entity)`, etc. conversion functions at module level
- Class constructor takes `db: AsyncSession`
- Create operations: `db.add(entity)``db.commit()``db.refresh(entity)` → convert
- Read operations: `db.execute(select(...).where(...))``scalar_one_or_none()` or `scalars().all()`
- Update operations: load entity → mutate → `db.commit()``db.refresh()` → convert
Key query patterns:
```python
# list_adventures_for_user — exclude soft-deleted, order newest first
select(AdventureEntity)
.where(AdventureEntity.user_id == user_id, AdventureEntity.deleted_at.is_(None))
.order_by(AdventureEntity.created_at.desc())
# list_entries_for_adventure — order by entry_index ascending
select(AdventureEntryEntity)
.where(AdventureEntryEntity.adventure_id == adventure_id)
.order_by(AdventureEntryEntity.entry_index.asc())
# count_complete_entries — for checking if adventure should be marked complete
select(func.count()).select_from(AdventureEntryEntity).where(
AdventureEntryEntity.adventure_id == adventure_id,
AdventureEntryEntity.status == "complete",
)
# get_decision_for_entry — find if user already made a choice on this entry
# Join decision → choice → entry to check entry_id
select(AdventureEntryPossibleChoiceDecisionEntity)
.join(
AdventureEntryPossibleChoiceEntity,
AdventureEntryPossibleChoiceDecisionEntity.choice_id == AdventureEntryPossibleChoiceEntity.id
)
.where(
AdventureEntryPossibleChoiceEntity.entry_id == entry_id,
AdventureEntryPossibleChoiceDecisionEntity.user_id == user_id,
)
```
---
## AnthropicClient Additions
**REVIEW FEEBACK**: Can we extract out the generation of the "system prompt" here to be more generic, and not tied specifically to the AnthropicClient. In the ports-and-adapters architecture we should view the AntrhopicClient as a way to interact with an LLM with a given prompt, rather than something which has business/domain decisions. I imagine some system components with a name like `adventure_generation_helpers`, which can generate both the system prompts, and also collate the previous conversations into one place - and then the LLM Clients (like AnthropicClient) could be passed mroe simply the text system prompt, and a list of dicts for previous messages to generate the next response.
File: `app/outbound/anthropic/anthropic_client.py`
Add two new methods. Keep the same `asyncio.to_thread(_call)` pattern.
### `generate_adventure_entry`
```python
async def generate_adventure_entry(
self,
language: str, # e.g. "French"
competencies: list[str], # e.g. ["B1"]
genres: list[str],
setting: list[str],
vibes: list[str],
protagonist: list[str],
max_entry_count: int,
entry_story_text_target_length: dict, # {"min": 700, "max": 800}
conversation_history: list[dict], # [{"role": "user"|"assistant", "content": str}]
model: str = "claude-sonnet-4-6",
) -> tuple[str, dict]:
"""Returns (response_text, usage_dict).
usage_dict: {"provider": "anthropic", "model": str, "input_tokens": int, "output_tokens": int}
"""
```
System prompt template (adapt from the design doc example):
```
You are an experienced tabletop game master running a single-player one-shot campaign in a "choose your own adventure" format.
You are helping the player learn {language}. Your writing respects their intelligence, avoids too many clichés, delivers satisfying plot beats, and reads naturally.
The session is {max_entry_count} turns. Each turn: you write a story passage, then offer
4 numbered choices. The player replies with their choice; you continue accordingly.
By turn {max_entry_count} there needs to be a clear end. As the player's choices reveal
their character, weave those details back into the story. Don't railroad them until at
least turn {max_entry_count * 0.5}.
Rules:
- Write entirely in {language} at {competency} level on the CEFR scale. No markdown — plaintext only.
- Your response MUST be in exactly three parts, each separated by a line containing only "-----".
- Part 1: the story entry, {min_length}{max_length} words, speaking directly to the player.
- Part 2: exactly 4 numbered player options, one per line, labelled "1.", "2.", "3.", "4.".
- Part 3: GM notes to your future self (hidden from the player). If no notes, write "no notes".
- Your first message must establish: who the player is, the setting, and the broad direction.
- No sexual content or graphic violence. Romance, threat, and adventure are fine (12-certificate).
```
Initial user message (first entry only; constructed in the service):
```
Please begin the adventure with the following details:
- Genre: {genres_joined}
- Setting: {setting_joined}
- Vibes: {vibes_joined}
- Protagonist: {protagonist_joined}
```
For subsequent entries, `conversation_history` already contains the setup message and all prior turns. The method appends it as the `messages` array.
### `generate_adventure_title_and_description`
```python
async def generate_adventure_title_and_description(
self,
first_entry_text: str,
language: str,
genres: list[str],
model: str = "claude-sonnet-4-6",
) -> tuple[str, str]:
"""Returns (title, description).
Asks the LLM for a short adventure title and a one-sentence description,
based on the first entry. Returns these as a (title, description) tuple.
Instruct the LLM to respond with exactly two lines:
Line 1: the title (plain text, no label, max 60 characters)
Line 2: the description (plain text, max 200 characters)
"""
```
---
## Service Layer
**REVIEW REVIEW** This service method has got too big, it's doing too many things. I like that it creates the Adventures and entires, and records decisions (that feels like what it _should_ be doing). Find some natural points to break out, e.g. I suspec that we could isolate the long-runningl logic in the `_run_entry_pipeline` method (though that's so domain-specific, and is very service-layer in its orchestration/choreographic role, so maybe it does belong here). But at least the LLM response parsing should be moved as separate - as the connector between the service layer and the outbound port/adapter part.
File: `app/domain/services/adventure_service.py`
```python
import uuid
from sqlalchemy.ext.asyncio import AsyncSession
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 AdventureRepository
from ...storage import upload_audio
from ..models.adventure import Adventure, AdventureEntry, AdventureEntryPossibleChoiceDecision
from ... import worker
class AdventureService:
def __init__(
self,
adventure_repo: AdventureRepository,
anthropic_client: AnthropicClient,
deepl_client: DeepLClient,
gemini_client: GeminiClient,
) -> None:
self.adventure_repo = adventure_repo
self.anthropic_client = anthropic_client
self.deepl_client = deepl_client
self.gemini_client = gemini_client
async def create_adventure_for_user(
self,
db: AsyncSession,
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,
) -> Adventure:
"""Creates the adventure record and enqueues first-entry generation."""
adventure = await self.adventure_repo.create_adventure(
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},
)
entry = await self.adventure_repo.create_entry(
adventure_id=uuid.UUID(adventure.id),
entry_index=0,
generated_from_choice_id=None,
)
await worker.enqueue(
lambda: self._run_entry_pipeline(db, uuid.UUID(adventure.id), uuid.UUID(entry.id))
)
return adventure
async def record_decision_and_generate_next_entry(
self,
db: AsyncSession,
adventure_id: uuid.UUID,
choice_id: uuid.UUID,
user_id: uuid.UUID,
) -> AdventureEntryPossibleChoiceDecision:
"""Validates and records the player's decision, then enqueues the next entry.
Raises ValueError if:
- The adventure does not belong to this user
- The adventure is not in 'active' status
- The choice does not belong to this adventure
- The user has already made a decision on this entry
"""
adventure = await self.adventure_repo.get_adventure_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(f"adventure_not_active:{adventure.status}")
choice = await self.adventure_repo.get_choice_by_id(choice_id)
if choice is None:
raise ValueError("choice_not_found")
# Verify this choice belongs to an entry in this adventure
entry = await self.adventure_repo.get_entry_by_id(uuid.UUID(choice.entry_id))
if entry is None or entry.adventure_id != str(adventure_id):
raise ValueError("choice_not_in_adventure")
# Prevent double-deciding on the same entry
existing_decision = await self.adventure_repo.get_decision_for_entry(
adventure_id=adventure_id,
entry_id=uuid.UUID(choice.entry_id),
)
if existing_decision is not None:
raise ValueError("decision_already_made")
decision = await self.adventure_repo.create_decision(
choice_id=choice_id, user_id=user_id
)
next_index = entry.entry_index + 1
next_entry = await self.adventure_repo.create_entry(
adventure_id=adventure_id,
entry_index=next_index,
generated_from_choice_id=choice_id,
)
await worker.enqueue(
lambda: self._run_entry_pipeline(db, adventure_id, uuid.UUID(next_entry.id))
)
return decision
async def _run_entry_pipeline(
self,
db: AsyncSession,
adventure_id: uuid.UUID,
entry_id: uuid.UUID,
) -> None:
"""Full entry generation pipeline. Runs in the worker queue.
On any error, marks the entry and adventure as 'error'.
"""
try:
adventure = await self.adventure_repo.get_adventure_by_id(adventure_id)
assert adventure is not None
all_entries = await self.adventure_repo.list_entries_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
# Build conversation history for all entries before this one
conversation_history = await self._build_conversation_history(
adventure=adventure,
entries=[e for e in all_entries if e.entry_index < current_entry.entry_index],
)
# LLM generation
from ...languages import SUPPORTED_LANGUAGES
language_name = SUPPORTED_LANGUAGES.get(adventure.language, adventure.language)
raw_text, usage_dict = await self.anthropic_client.generate_adventure_entry(
language=language_name,
competencies=adventure.competencies,
genres=adventure.genres,
setting=adventure.setting,
vibes=adventure.vibes,
protagonist=adventure.protagonist,
max_entry_count=adventure.max_entry_count,
entry_story_text_target_length=adventure.entry_story_text_target_length,
conversation_history=conversation_history,
)
# Parse LLM response
story_text, choices_parsed, gm_notes = parse_llm_response(raw_text)
# Persist entry content
await self.adventure_repo.update_entry_content(
entry_id=entry_id,
story_text=story_text,
gamemaster_notes=gm_notes,
llm_data=usage_dict,
status="complete",
)
# Persist choices — omit for the final entry
if not is_final_entry:
choices_for_db = [
(i, label, text) for i, (label, text) in enumerate(choices_parsed)
]
await self.adventure_repo.create_choices(entry_id=entry_id, choices=choices_for_db)
# Translation
translated = await self.deepl_client.translate(story_text, adventure.source_language)
await self.adventure_repo.create_translation(
entry_id=entry_id,
component_type="story_text",
target_language=adventure.source_language,
translated_text=translated,
)
# TTS audio
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.adventure_repo.create_audio(
entry_id=entry_id,
component_type="story_text",
tts_provider="google_gemini",
tts_options={"voice": voice},
file_name=audio_key,
)
# Update adventure: title (first entry only) and status
if is_first_entry:
title, description = await self.anthropic_client.generate_adventure_title_and_description(
first_entry_text=story_text,
language=language_name,
genres=adventure.genres,
)
await self.adventure_repo.update_adventure_title_and_description(
adventure_id=adventure_id, title=title, description=description
)
new_adventure_status = "complete" if is_final_entry else "active"
await self.adventure_repo.update_adventure_status(
adventure_id=adventure_id, status=new_adventure_status
)
except Exception:
import logging
logging.getLogger(__name__).exception("Entry pipeline failed for entry %s", entry_id)
await self.adventure_repo.update_entry_status(entry_id=entry_id, status="error")
await self.adventure_repo.update_adventure_status(
adventure_id=adventure_id, status="error"
)
async def _build_conversation_history(
self,
adventure: "Adventure",
entries: list["AdventureEntry"],
) -> list[dict]:
"""Reconstruct the full conversation history for the LLM from stored entries.
Returns a list of {role, content} dicts ready to pass to Anthropic.
The first message is always the initial user setup message.
"""
from ...languages import SUPPORTED_LANGUAGES
language_name = SUPPORTED_LANGUAGES.get(adventure.language, adventure.language)
competency = adventure.competencies[0] if adventure.competencies else "B1"
setup_message = (
f"Please begin the adventure with the following details:\n"
f"- Genre: {', '.join(adventure.genres)}\n"
f"- Setting: {', '.join(adventure.setting)}\n"
f"- Vibes: {', '.join(adventure.vibes)}\n"
f"- Protagonist: {', '.join(adventure.protagonist)}"
)
history: list[dict] = [{"role": "user", "content": setup_message}]
for entry in sorted(entries, key=lambda e: e.entry_index):
choices = await self.adventure_repo.get_choices_for_entry(uuid.UUID(entry.id))
assistant_text = _reconstruct_entry_response(entry, choices)
history.append({"role": "assistant", "content": assistant_text})
# Find the user's choice that led to the next entry
# (The next entry's generated_from_choice_id tells us which choice was made)
next_entries = [
e for e in entries if e.entry_index == entry.entry_index + 1
]
if next_entries and next_entries[0].generated_from_choice_id:
chosen_choice_id = uuid.UUID(next_entries[0].generated_from_choice_id)
chosen_choice = next(
(c for c in choices if c.id == str(chosen_choice_id)), None
)
if chosen_choice:
history.append({"role": "user", "content": chosen_choice.label})
return history
def parse_llm_response(text: str) -> tuple[str, list[tuple[str, str]], str]:
"""Parse LLM response into (story_text, choices, gm_notes).
choices is a list of (label, text) e.g. [("1", "Go into the house"), ...]
Raises ValueError if the response format cannot be parsed.
"""
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() # In case gm_notes themselves contain dashes
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 _reconstruct_entry_response(
entry: "AdventureEntry",
choices: list["AdventureEntryPossibleChoice"],
) -> str:
"""Rebuild the original LLM response format 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}"
```
---
## API Routes
File: `app/routers/api/adventures.py`
```python
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
import uuid
from ...auth import verify_token
from ...outbound.postgres.database import get_db
# ... imports for service, repo, clients
router = APIRouter(prefix="/adventures", tags=["adventures"])
```
### Dependency factory
```python
def _service(db: AsyncSession) -> AdventureService:
from ...config import settings
return AdventureService(
adventure_repo=PostgresAdventureRepository(db),
anthropic_client=AnthropicClient.new(settings.anthropic_api_key),
deepl_client=DeepLClient(settings.deepl_api_key),
gemini_client=GeminiClient(settings.gemini_api_key),
)
```
### `POST /api/adventures`
Creates a new adventure and enqueues first-entry generation.
**Request body:**
```python
class CreateAdventureRequest(BaseModel):
language: str # ISO code, e.g. "fr"
source_language: str # ISO code, e.g. "en"
competencies: list[str] # e.g. ["B1"]
genres: list[str]
setting: list[str]
vibes: list[str]
protagonist: list[str]
max_entry_count: int = 6
```
**Response:** `201 AdventureResponse`
```python
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 # ISO datetime
```
**Errors:** `400` if `language` is not in `SUPPORTED_LANGUAGES` or DeepL cannot translate `source_language`.
**Handler:**
```python
@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"])
# Validate language support
adventure = await _service(db).create_adventure_for_user(
db=db, user_id=user_id, **body.model_dump()
)
return _to_adventure_response(adventure)
```
---
### `GET /api/adventures`
Returns all non-deleted adventures for the authenticated user.
**Response:** `200 list[AdventureResponse]`
---
### `GET /api/adventures/{adventure_id}`
Returns a single adventure (must belong to the authenticated user).
**Response:** `200 AdventureResponse`
**Errors:** `404` if not found or belongs to another user.
---
### `DELETE /api/adventures/{adventure_id}`
Soft-deletes the adventure by setting `deleted_at`.
**Response:** `204 No Content`
**Errors:** `404` if not found or belongs to another user.
---
### `POST /api/adventures/{adventure_id}/decisions`
Records the player's choice and enqueues the next entry.
**Request body:**
```python
class CreateDecisionRequest(BaseModel):
choice_id: str # UUID of AdventureEntryPossibleChoice
```
**Response:** `201 DecisionResponse`
```python
class DecisionResponse(BaseModel):
id: str
choice_id: str
user_id: str
created_at: str
```
**Errors:**
| Condition | Status |
|-----------|--------|
| Adventure not found or belongs to another user | `404` |
| Adventure status is not `active` | `409` with detail `"adventure_not_active"` |
| Choice does not belong to this adventure | `404` |
| Decision already made for this entry | `409` with detail `"decision_already_made"` |
**Handler:** Parse `ValueError` from service into appropriate `HTTPException`.
---
### `GET /api/adventures/{adventure_id}/entries`
Returns all entries for the adventure (ordered by `entry_index` ascending).
**Response:** `200 list[EntryResponse]`
```python
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
```
**Errors:** `404` if adventure not found or belongs to another user.
---
### `GET /api/adventures/{adventure_id}/entries/{entry_id}`
Returns a single entry. Includes choices, translation, and audio URL.
**Response:** `200 EntryDetailResponse`
```python
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 # translated_text for 'story_text' component
audio_url: str | None # pre-signed S3 URL if audio exists; None otherwise
```
Audio URL generation: use the existing `download_audio` / S3 pattern, or generate a pre-signed URL with a 1-hour expiry.
**Errors:** `404` if adventure or entry not found / unauthorised.
## Route Registration
### `app/routers/api/main.py` — add:
```python
from .adventures import router as adventures_router
api_router.include_router(adventures_router)
```
---
## Configuration
No new environment variables are required. The adventure service uses the existing `anthropic_api_key`, `deepl_api_key`, and `gemini_api_key` from `Settings`.
---
## Tests
File: `tests/test_adventures.py`
All tests follow the existing patterns in `tests/test_packs.py` and `tests/test_auth.py`:
- `httpx.Client` against the test Docker container
- Each test uses fresh data with unique emails
- Register + login to get a Bearer token
- `Authorization: Bearer <token>` header on authenticated requests
The worker queue runs in-process in the test server. Since generation involves real API calls (expensive/slow for tests), stub the generation clients using a test configuration. The recommended approach is:
- Add a `STUB_GENERATION=true` environment variable in `docker-compose.test.yml`
- When `STUB_GENERATION=true`, `AnthropicClient.generate_adventure_entry` returns a hardcoded valid response string instead of calling the real API
- Similarly stub `DeepLClient.translate` and `GeminiClient.generate_audio`
- This keeps tests fast, isolated, and free of external API dependencies
Stub response for `generate_adventure_entry`:
```
Vous vous retrouvez dans une ruelle sombre de Paris. Une silhouette s'approche.
-----
1. Suivez la silhouette
2. Restez dans l'ombre
3. Demandez de l'aide
4. Courez vers la lumière
-----
no notes
```
Stub response for `generate_adventure_title_and_description`:
```
La Nuit Parisienne
Une aventure mystérieuse dans les rues sombres de Paris.
```
### Test helper
```python
import time
def _wait_for_adventure_status(client, adventure_id: str, expected_status: str, timeout: int = 15):
"""Poll until adventure.status matches, or raise TimeoutError."""
deadline = time.time() + timeout
while time.time() < deadline:
resp = client.get(f"/api/adventures/{adventure_id}")
if resp.json()["status"] == expected_status:
return resp.json()
time.sleep(0.5)
raise TimeoutError(f"Adventure {adventure_id} did not reach status '{expected_status}'")
```
---
### Test 1: Adventure creation and first-entry pipeline
```python
def test_create_adventure_generates_first_entry(user_client):
resp = user_client.post("/api/adventures", json={
"language": "fr",
"source_language": "en",
"competencies": ["B1"],
"genres": ["crime fiction"],
"setting": ["Paris", "city"],
"vibes": ["dark"],
"protagonist": ["male", "late-teens"],
})
assert resp.status_code == 201
adventure_id = resp.json()["id"]
assert resp.json()["status"] == "awaiting_first_entry"
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
entry_resp = user_client.get(f"/api/adventures/{adventure_id}/entries/{entries[0]['id']}")
assert entry_resp.status_code == 200
detail = entry_resp.json()
assert len(detail["choices"]) == 4
assert detail["translation"] is not None
assert detail["audio_url"] is not None
```
---
### Test 2: Recording a decision and generating the next entry
```python
def test_record_decision_generates_next_entry(user_client):
resp = user_client.post("/api/adventures", json={...}) # same as above
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()
first_entry_id = entries[0]["id"]
first_entry_detail = user_client.get(
f"/api/adventures/{adventure_id}/entries/{first_entry_id}"
).json()
choice_id = first_entry_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_entry = next(e for e in entries if e["entry_index"] == 1)
assert second_entry["status"] == "complete"
assert second_entry["generated_from_choice_id"] == choice_id
```
---
### Test 3: Adventure completes after max_entry_count entries
```python
def test_adventure_completes_at_max_entries(user_client):
# Use max_entry_count=2 to keep the test fast (requires only 1 decision)
resp = user_client.post("/api/adventures", json={
...,
"max_entry_count": 2,
})
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()
first_entry_detail = user_client.get(
f"/api/adventures/{adventure_id}/entries/{entries[0]['id']}"
).json()
choice_id = first_entry_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"
# Final entry should have no choices
entries = user_client.get(f"/api/adventures/{adventure_id}/entries").json()
assert len(entries) == 2
final_entry = user_client.get(
f"/api/adventures/{adventure_id}/entries/{entries[1]['id']}"
).json()
assert final_entry["choices"] == []
# Decision on a complete adventure returns 409
resp = user_client.post(
f"/api/adventures/{adventure_id}/decisions",
json={"choice_id": choice_id},
)
assert resp.status_code == 409
assert "not_active" in resp.json()["detail"]
```
---
### Test 4: Double-decision prevention
```python
def test_cannot_make_second_decision_on_same_entry(user_client):
resp = user_client.post("/api/adventures", json={...})
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()
first_entry_detail = user_client.get(
f"/api/adventures/{adventure_id}/entries/{entries[0]['id']}"
).json()
choice_id = first_entry_detail["choices"][0]["id"]
other_choice_id = first_entry_detail["choices"][1]["id"]
user_client.post(
f"/api/adventures/{adventure_id}/decisions", json={"choice_id": choice_id}
)
# Wait briefly for state to settle (decision is sync; generation is async)
resp = user_client.post(
f"/api/adventures/{adventure_id}/decisions", json={"choice_id": other_choice_id}
)
assert resp.status_code == 409
assert "decision_already_made" in resp.json()["detail"]
```
---
### Test 5: User isolation
```python
def test_user_cannot_access_another_users_adventure(user_client, second_user_client):
resp = user_client.post("/api/adventures", json={...})
adventure_id = resp.json()["id"]
# Second user cannot GET it
resp = second_user_client.get(f"/api/adventures/{adventure_id}")
assert resp.status_code == 404
# Second user cannot POST a decision on it
resp = second_user_client.post(
f"/api/adventures/{adventure_id}/decisions", json={"choice_id": str(uuid.uuid4())}
)
assert resp.status_code == 404
```
---
### Unit test: LLM response parser
These run without Docker and test parsing edge cases directly:
```python
# tests/test_adventure_parser.py (or inside test_adventures.py)
from app.domain.services.adventure_service import parse_llm_response
def test_parse_valid_three_section_response():
text = "Story text here.\n-----\n1. Option one\n2. Option two\n3. Option three\n4. Option four\n-----\nno notes"
story, choices, notes = parse_llm_response(text)
assert story == "Story text here."
assert len(choices) == 4
assert choices[0] == ("1", "Option one")
assert notes == "no notes"
def test_parse_with_period_delimiters():
text = "Story.\n-----\n1) First\n2) Second\n3) Third\n4) Fourth\n-----\nGM note here."
story, choices, notes = parse_llm_response(text)
assert len(choices) == 4
assert choices[2] == ("3", "Third")
def test_parse_missing_sections_raises():
import pytest
with pytest.raises(ValueError, match="section"):
parse_llm_response("Only one section here, no separators.")
```