Compare commits

..

12 commits

24 changed files with 4221 additions and 6 deletions

View file

@ -1,7 +1,7 @@
.PHONY: down build up logs shell lock migrate migration import-dictionary
build:
docker compose build
docker compose build --no-cache
up:
docker compose up -d

161
api/CLAUDE.md Normal file
View file

@ -0,0 +1,161 @@
# Language Learning App — API
Python/FastAPI HTTP API. See `docs/architecture.md` for design principles.
## Stack
- **Python 3.13**, FastAPI, Uvicorn (ASGI)
- **PostgreSQL** via SQLAlchemy 2.0 async (asyncpg driver) + Alembic migrations
- **Auth**: JWT (PyJWT), passwords hashed with passlib pbkdf2_sha256
- **External APIs**: Anthropic (text generation), Google Gemini (TTS), DeepL (translation), Deepgram (STT), spaCy (NLP)
- **Storage**: S3-compatible object store via boto3 (`app/storage.py`)
- **Background work**: in-process `asyncio.Queue` worker (`app/worker.py`)
- **Tests**: pytest + httpx against a real Docker stack (`tests/conftest.py`)
## Architecture: Domain-Driven + Hexagonal
```
app/
domain/
models/ # Pure dataclasses — NO ORM, NO methods, just data
services/ # Orchestration logic; take repos as constructor params
routers/
api/ # RESTful resource endpoints (/api/...)
bff/ # Screen-specific read-only endpoints (/bff/...) — GET only
outbound/
postgres/
entities/ # SQLAlchemy ORM table definitions
repositories/ # CRUD; the ONLY place ORM entities are touched
anthropic/ # AnthropicClient
gemini/ # GeminiClient
deepl/ # DeepLClient
deepgram/ # DeepgramClient
spacy/ # SpacyClient
auth.py # JWT helpers: create_access_token, verify_token, require_admin
config.py # Pydantic Settings (reads from .env)
storage.py # S3 upload/download helpers
worker.py # worker_loop() + enqueue()
```
## Key Patterns
### Domain models
Plain dataclasses. IDs stored as `str` (UUID converted at repo boundary).
```python
@dataclass
class Flashcard:
id: str
user_id: str
created_at: datetime
```
### ORM entities
SQLAlchemy 2.0 `Mapped[]` style. UUIDs as primary keys with `default=uuid.uuid4`.
```python
class FlashcardEntity(Base):
__tablename__ = "flashcard"
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)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
```
### Repositories
Protocol + Postgres implementation. Private `_to_model(entity)` function at module level.
```python
class PostgresFooRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def create(self, ...) -> Foo:
entity = FooEntity(...)
self.db.add(entity)
await self.db.commit()
await self.db.refresh(entity)
return _to_model(entity)
```
### Services
Constructor receives all needed repos. Domain-language method names.
```python
class FooService:
def __init__(self, foo_repo: FooRepository, bar_repo: BarRepository) -> None:
...
async def add_foo_for_user(self, user_id: uuid.UUID, ...) -> Foo:
... # raises ValueError on bad input
```
### Routers
- `Depends(get_db)` for the DB session
- `Depends(verify_token)` for auth; extracts `user_id = uuid.UUID(token_data["sub"])`
- `Depends(require_admin)` for admin-only endpoints; use `_: dict = Depends(require_admin)` if token_data unused
- Private `_service(db)` factory function instantiates service + repos
- `ValueError` from service → `HTTPException` with appropriate status code
- Private `_to_response(model)` converts domain model to Pydantic response
```python
def _service(db: AsyncSession) -> FooService:
return FooService(foo_repo=PostgresFooRepository(db))
@router.post("", response_model=FooResponse, status_code=201)
async def create_foo(
body: CreateFooRequest,
db: AsyncSession = Depends(get_db),
token_data: dict = Depends(verify_token),
) -> FooResponse:
user_id = uuid.UUID(token_data["sub"])
try:
foo = await _service(db).add_foo_for_user(user_id, ...)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc))
return _to_response(foo)
```
### Background work
Enqueue callables into the in-process worker queue. The worker runs one task at a time.
```python
await worker.enqueue(lambda: some_service.do_work(db, entity_id))
```
The `SummariseService.run()` is the canonical example: LLM → translate → TTS → S3 upload, all in one async method, called from a worker task. Use `_anthropic_with_backoff()` (defined in `summarise_service.py`) for retryable Anthropic calls.
### External clients
All use `asyncio.to_thread(_call)` to wrap synchronous SDK calls.
- `AnthropicClient.new(api_key)` — text generation; model hardcoded as `"claude-sonnet-4-6"`
- `GeminiClient(api_key)` — TTS via `generate_audio(text, voice)`; `get_voice_by_language(lang)` maps ISO codes to voice names
- `DeepLClient(api_key)``translate(text, to_language)`; check `can_translate_to(lang)` first
- `GeminiClient` uses `gemini-2.5-flash-preview-tts` model; returns PCM converted to WAV via `pcm_to_wav()`
### Migrations
Naming: `YYYYMMDD_NNNN_description.py`.
Always include `downgrade()` that reverses `upgrade()` in reverse order. Use `postgresql.JSONB()` for arrays/objects, `sa.func.now()` for server-side timestamp defaults.
### Tests
Session-scoped `docker_stack` fixture brings up `docker-compose.test.yml` (project `langlearn-test`, API on port 18000). Each test gets a fresh `httpx.Client`. Register + login to get a token; set `client.headers["Authorization"] = f"Bearer {token}"`.
## Route Registration
Add new routers in:
- `app/routers/api/main.py``api_router.include_router(...)`
- `app/routers/bff/main.py``bff_router.include_router(...)`
## Config
All settings in `app/config.py` via `pydantic_settings.BaseSettings` (reads `.env`). Access via `from .config import settings`. Required keys: `database_url`, `jwt_secret`, `anthropic_api_key`, `deepl_api_key`, `deepgram_api_key`, `gemini_api_key`, `storage_endpoint_url`, `storage_access_key`, `storage_secret_key`.
## Existing Domain Areas
| Area | Models | Router prefix |
|------|--------|---------------|
| Auth | `Account` | `/api/auth` |
| Vocabulary | `LearnableWordBankEntry`, `UserLanguagePair` | `/api/vocab` |
| Flashcards | `Flashcard`, `FlashcardEvent` | `/api/flashcards` |
| Packs | `WordBankPack`, `WordBankPackEntry` | `/api/packs`, `/api/admin/packs` |
| Articles | `TranslatedArticle`, `SummariseJob` | `/api/generation`, `/api/jobs` |
| Adventures | (being built — see `docs/technical-doc-choose-your-own-adventure.md`) | `/api/adventures` |

View file

@ -12,6 +12,7 @@ import app.outbound.postgres.entities.summarise_job_entity
import app.outbound.postgres.entities.user_entity
import app.outbound.postgres.entities.dictionary_entities
import app.outbound.postgres.entities.pack_entities
import app.outbound.postgres.entities.adventure_entities
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)

View file

@ -0,0 +1,208 @@
"""add choose_your_own_adventure tables
Revision ID: 0016
Revises: 0015
Create Date: 2026-05-03
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "0016"
down_revision: Union[str, None] = "0015"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
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"])
# Entry table — generated_from_choice_id FK added after possible_choice table is created
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,
),
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"]
)
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"],
)
# Resolve circular FK: 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",
)
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"],
)
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"],
)
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")

View file

@ -19,6 +19,7 @@ class Settings(BaseSettings):
storage_access_key: str
storage_secret_key: str
storage_bucket: str = "langlearn"
stub_generation: bool = False
model_config = {"env_file": ".env"}

View 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

View 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

View 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

View file

@ -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,

View 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)

View 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

View 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,
)

View file

@ -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)

View file

@ -0,0 +1,52 @@
# Technical Design Doc: Articles
> You may wish to review documentation about [architecture](./architecture.md) and the [domain](./domain.md) of this application to help make sense of this document.
An Article represents a single piece of content that a learner can read and/or listen to. It might be, for example, a 300-word fictional piece about a baker in Lyon, or it could be a 500-word summary of recent news events.
Articles will be accompanies by a (AI-generated) text-to-speech.
Because this is a language-learning app, Articles will be authored in one language (e.g. French) and there will be a parallel set of content in another language (e.g. English).
Not every learner will have access to every Article at the same time. For example, learners who are studying French won't access Italian language Articles. Intermediate French learners won't access advanced or basic French language Articles.
Because Articles can be available in Audio, Articles will also form the basis of a podcast-style RSS feed for each learner. Allowing them to _just_ listen.
The Article is therefore the primitive of the content, but not how a learner will access, or receive, their content. There will need to be a separate piece of architecture which makes Articles available to the learner, e.g. through a daily or weekly "edition" of content from the website (similar to a newspaper)
A separate role of Users will author, edit, and publish Articles - using a traditional CMS-like interface. Articles can therefore be in a _draft_ state, before they are published. Articles are also versioned entities, i.e. if I wish to make a change to an article, as an author, I would log in, make that change, and then click "update" or "publish", which would then kick off an async process to replace the previous article version with the new one. Primarily this is because of the audio-generation pipeline of an Article.
## Foundational data model
In the interest of delivering value incrementally, as opposed to "all at once", let's create the following entities:
The Article entity is the Header that contains a reference to the content, describing the article itself:
```json
{
"id": "article_id",
"source_language": "fr",
"target_language": "en",
"title": "Le boulangerie",
"subtitle": null, // nullable string
"subject_tags": ["fiction", "france"],
"length_descriptor": "short" // short,medium,long,
}
```
And this is the "record" row of the Article, which we could call the ArticleVersion:"
```json
{
"id": "some-uuid",
"article_id": "article_id",
"created_at": "2026-04-22T19:00Z",
"published_at": "2026-04-24T09:00Z", // nullable, if not published,
"deleted_at": null,
"source_language_markdown_text": "This is where the article is",
"target_language_markdown_text": "voila la langue franciase", // nullable if not generated
"source_language_natural_language_data": {..}, // nullable, output from SpaCy tokensation
"target_language_natural_language_data": {..}, // nullable, output from SpaCy tokensation
"source_language_audio_url": "http://", // nullable if not generated
}
```

View file

@ -0,0 +1,174 @@
# Feature design doc: Choose your own adventure
This is a semi-technical design document to detail the *Choose Your Own Adventure* functionality of the Langauge Learning App.
## Purpose
Improve learner familiarity with a foreign language by exposing them to content generated in that language.
The Choose Your Own Adventure format is chosen because it is fictitious (not all content should be non-fiction, or for "learning"). They are also engaging in that they require a little input from the user, and can be guided (at a high level) by the learner.
## Feature Description
In the website there is a tab, or page, called "Adventures".
On this page, learners are ablet to see any completed `adventures` (Adventures have a target number of `entries`, let's default to 6) - an adventure is *complete* when the target number of `entries` has been reached for it.
When a learner creates an Adventure, they select a handful of details to aid the generation: the genre of story they want (e.g. crime fiction), the setting they want it to be in (i.e. roughly when and where), the vibes of the story (e.g. "cosy" or "thriller"), and lastly the protagonist (i.e. gender, age, one characteristic).
An LLM is then used to generate the first entry in the adventure, which will introduce the learner to the story, and their character. After this has been received, we ask an LLM to create a name and a description for the adventure based on what comes back from this first entry. An Adventure now has its name, description, and some lose content tags.
Each entry that gets created will receive a translation of the *Story Text* from the learner's target language, into their source language. This allows for parallel reading of the text. In time, as well, we will do natural language processing on source and target in an attempt to match sentence for sentence, or word for word, to create a better way to do the parallel reading.
Each entry will also have text-to-speech done (by AI), and this can be read through the user interface, but also in the future this will allow for a per-adventure podcast feed to be generated for the learner so they can learn on the go.
At the end of the entry is a set of next steps, or options, avaiable to the user. Initially there will be 4. The learner will chose one, which will repeat the cycle above (generate entry, translate, do text-to-speech, learner views it, etc.)
Once the learner has run through this cycle until they have reached the target number of entries, the last entry will not have any next-step options to generate.
Initially the learner will have to go and create a new Adventure, however, in the future it should be possible for them to go back to a branching point in the narrative and re-continue.
If the learner is reading the adventure through the LLA's own web UI there should be a way to quick-create flashcards, and/or add words to the learner's vocabulary / word list, identifying them as words they had to look up, and perhaps as words they want to learn in the future.
Additionally, over time, it would be good to generate another set of data (likely also from LLMs) that does key entity extraction from the text, and prevents stories from continually taking place in the same place, with similarly named characters. This would then be fet into the generation / system prompt, e.g. "avoid characters called Detective Renoir, avoid Paris,avoid the early 1950s" to create a variety of content.
## Monetisation and payment strategy
See the [pricing.md](./design-doc-pricing.md) doc for more info.
The use of LLMs creates a cost on Language Learning App per entry that is generated (initial generation, translation, text-to-speech). This will likely be as high as 50-60p per adventure, per user this could add up to a lot of money.
Users who wish to operate on the subscription model will get a certain number of Adventure entries per subscription period. We should round this up to the nearest adventure (you don't want to be waiting for your next renewal to finsih an adventure).
Users on a metered billing will pay for a whole adventure up-front, i.e. aprox. $1.20/adventure.
For this reason, it's very important that the system tracks the costs (in money, and in tokens) taken to generate the content for an adventure, so these figures can be adjusted to reflect reality.
## Example prompts
```txt
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 French. Your writing respects their intelligence, avoids too many cliches, delivers satisfying plot beats, and reads naturally.
The session is 8 turns. Each turn: you write a story passage, then offer 4 numbered choices. The player replies with their choice; you continue accordingly. By turn 8 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 4
Rules:
- Write entirely in French at B1 level. No markdown — plaintext only.
- Your response should be in three parts, each separated by a newline, and then five hyphens ("-----").
- The first section contains the story entry, 600700 words length total, speaking to the player directly.
- The second section contains contains a list of new-line separated player options, labelled 1,2,3,4 with explaining text.
- The third section are GM notes, hidden from the player, you may optionally use this section to record notes to your future self, to keep track of threads or ideas. If no notes, simply say "no notes"
- Your first message must establish: who the player is, the setting, and the broad direction of the story.
- No sexual content or graphic violence. Romance, threat, and adventure are fine. Treat this as a 12-certificate.
The scenario follows.
```
## Entities
### choose_your_own_adventure
This is the "header" entry, it represnts a single "adventure" in the format, right now it's linked to one user (via `user_id`) and holds details about the language and proficiency it's in, as a record of what was selected at the time.
The title is `Untitled adventure` and the description is empty when it gets created, but a separate call to an LLM will create a name and a description to put here.
```json
{
"id": "unique-uuid",
"user_id": "user-uuid",
"language": "fr",
"competencies": ["B1"],
"max_entry_length": 8,
"entry_story_text_target_length": { "min": 700, "max": 800},
"title": "Untitled adventure",
"description": null,
"plot_summary": null,
"genres": ["crime fiction"],
"setting": ["France", "city"],
"vibes": ["dark", "light humour"],
"protagonist": ["male", "reluctant", "late-teens"],
"created_at": "2026-05-03T09:00Z",
"deleted_at": null,
}
```
### choose_your_own_adventure_entry
An entry is like a "turn" in a tabletop roleplaying game, or a chapter in a choose your own adventure book. These are generated one at a time, in response to user choices (the first one is generated immediately after creation of the Adventure itself).
They are generated by an LLM using a prompt.
They are immediately translated (via DeepL) and have text-to-speech (via Google Gemini) from the story_text content.
Recording the `entry_index` and the `generated_from_possible_choice_id` allows us to model multiple replays of a specific adventure (e.g. "go back to step 3, and choose a different option to what I initially chose).
```json
{
"id": "uuid",
"choose_your_own_adventure_id": "unique-uuid",
"generated_from_possible_choice_id": "choose_your_own_adventure_entry_possible_choice-uuid", // null on entry 0
"llm_data": { "provider": "anthropic", "model": "claude-4.6" }, // JSONB for arbitrary data
"entry_index": "1", //
"story_text": "You find yourself in a big, dark woods...",
"gamemaster_notes": "The player is playing cautiously...", // Hidden from the user
"created_at": "2026-05-03T09:05",
}
```
### choose_your_own_adventure_entry_translation
This represents a translation of the generated story_text into the user's native language, to help them do parallel reading between the two texts.
```json
{
"id": "uuid",
"entry_id": "choose-your-own-adventure-entry-uuid",
"component_type": "story_text",
"target_language": "en",
"translated_text": "This is the translated text from the entry.story_text"
}
```
### choose_your_own_adventure_entry_audio
This is a text-to-speech (AI) generation of the story text, to make the content available to the user as e.g. a podcast feed, and also available on the screen.
```json
{
"id": "uuid",
"entry_id": "choose-your-own-adventure-entry-uuid",
"component_type": "story_text",
"tts_provider": "google_gemini",
"tts_options": { "voice": "voice name"}, // JSONB format
"file_name": "uuid-like-filename.mp4"
}
```
### choose_your_own_adventure_entry_possible_choice
This represents the options available to the user a the end of a specific entry, the LLM will generate 4 of them (initially).
```json
{
"id": "uuid",
"entry_id": "choose-your-own-adventure-entry-uuid",
"index": 0,
"label": "1",
"text": "Go into the dark house"
}
```
### choose_your_own_adventure_entry_possible_choice_decision
This represents the possible_choice that a user chose, which will be used to generate the next step of the story.
```json
{
"id": "uuid",
"choice_id": "choose_your_own_adventure_entry_possible_choice-uuid",
"user_id": "user-uuid",
"created_at": "2026-05-03T10:00:00.000Z"
}
```

View file

@ -1,5 +1,31 @@
# Pricing
Language Learning App doesn't like that everything has become a subscription, and wants to offer users different ways to pay that suit them.
The exact pricing structure of Language Learning App has yet to be decided, but it costs money to run, and offers value to the user, so it won't be free to use. The use of generative AI incurs costs as the users use it, whereas other costs are trivial (e.g. creation of flashcards). Other bits of content (e.g. evergreen topics) could be shared by many users, so access to them could be cheaper.
## Ballpark figures
Users could pay as little as $40/yr and get access to unlimited flashcard creation and testing, and get access to the (e.g. weekly) evergreen content in their language. This provides *some* way to access content in a language they want to learn.
I would expect a minimum spend per user of $5/mo, and maximum to be $50/mo.
I would expect to see at least a 100% mark-up on the use of AI systems to generate personalised (i.e. user-specific) content, so e.g. if it costs 10p to generate some evergreen content, the cost to the user should be 20p.
For shared content, the cost to each user should be (roughly) the cost that it took to generate, i.e. 10p generation would cost 10p to the user.
## Pay-as-you-go
Each type of action in the system has an associated cost, which will be modelled in credits. E.g. the generation of a Choose Your Own Adventure could consume 100 credits, the generation of a set of flashcards for a specific word (with spoken text-to-speech) could cost 1 credit. These numbers are made up.
Users can purchase packs of credits to their accounts, and then as they engage in specific activities, their balance goes down.
## Subscriptions
For users who want the more predictable pricing, there should be an option for subscriptions, with tiers.
Users who *only* want flashcard content, and text (for example), could be on a cheap plan that allows e.g. 1 choose your own adventure entry every day, and the generation of 150 new flashcards.
## Ideas
- Dynamic subscription based pricing where the floor is lower if the learner commits to more learning activity. Price paid at the end of the month decreases for each day where the learner does a certain amount of activity.

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -42,6 +42,20 @@
--color-surface-container-highest: #e2e1dd;
--color-surface-dim: #d6dcd2; /* recessed utility */
/* --- Colour: Grey pallette, based on tailwind's default grey --- */
--colour-grey-50: #f9fafb;
--colour-grey-100: #f3f4f6;
--colour-grey-200: #e5e7eb;
--colour-grey-300: #d1d5db;
--colour-grey-400: #9ca3af;
--colour-grey-500: #6b7280;
--colour-grey-600: #4b5563;
--colour-grey-700: #374151;
--colour-grey-800: #1f2937;
--colour-grey-900: #111827;
--colour-grey-950: #030712;
--colour-grey-1000: #000000;
/* --- Color: On-Surface --- */
--color-on-surface: #2f342e; /* replaces pure black */
--color-on-surface-variant: #5c605b;

View file

@ -0,0 +1,45 @@
<script lang="ts">
interface Props {
promptText: string;
correctAnswers: string[];
}
let { promptText, correctAnswers }: Props = $props();
let mode: 'guess' | 'reveal' = 'reveal';
</script>
<section class="flashcard">
<div class="prompt-text">
<p class="prompt-text__text">{promptText}</p>
</div>
<div class="answer-text">
<label for="answer" class="label">Answer</label>
<input type="text" id="answer" class="input" />
</div>
{#if mode === 'reveal'}
<div class="correct-answers">
<p class="correct-answers__label">Correct Answers:</p>
<ul class="correct-answers__list">
{#each correctAnswers as answer}
<li class="correct-answers__item">{answer}</li>
{/each}
</ul>
</div>
{/if}
</section>
<style>
.flashcard {
padding: var(--space-1);
display: grid;
grid-template-columns: 1fr;
place-items: center;
max-width: 500px;
border: 1px solid var(--colour-grey-300);
}
.prompt-text {
}
</style>

View file

@ -1,17 +1,27 @@
import { query, getRequestEvent } from '$app/server';
import * as v from 'valibot';
import {
searchWordformsApiDictionarySearchGet,
searchWordformsPrefixApiDictionarySearchGet,
searchWordformsApiDictionaryWordformsGet
} from '../../../../client';
import { COOKIE_NAME_AUTH_TOKEN } from '$lib/auth';
export type DictionarySearchResult = {
lemma: {
text: string;
};
senses: {
text: string;
id: string;
}[];
};
export const dictionarySearch = query(
v.object({
text: v.string(),
langCode: v.string()
}),
async ({ langCode, text }) => {
async ({ langCode, text }): Promise<DictionarySearchResult[]> => {
const { cookies } = getRequestEvent();
const trimmed = text.trim();
@ -25,14 +35,26 @@ export const dictionarySearch = query(
query: { lang_code: langCode, text }
});
return data;
return (data ?? []).map(({ lemma, senses }) => ({
lemma: { text: lemma.headword },
senses: senses.map(({ gloss, id }) => ({
text: gloss,
id
}))
}));
} else {
const { data } = await searchWordformsApiDictionarySearchGet({
const { data } = await searchWordformsPrefixApiDictionarySearchGet({
headers: { Authorization: `Bearer ${cookies.get(COOKIE_NAME_AUTH_TOKEN)}` },
query: { lang_code: langCode, text }
});
return data;
return (data ?? []).map(({ lemma, senses }) => ({
lemma: { text: lemma.headword },
senses: senses.map(({ gloss, id }) => ({
text: gloss,
id
}))
}));
}
}
);

View file

@ -0,0 +1,6 @@
<script>
import FlashcardForm from './FlashcardForm.svelte';
</script>
<h1>New Flashcard</h1>
<FlashcardForm />

View file

@ -0,0 +1,58 @@
<script lang="ts">
import Flashcard from '$lib/components/Flashcard.svelte';
import {
dictionarySearch,
type DictionarySearchResult
} from '../../admin/dictionary-search/dictionarySearch.remote';
let dictionarySearchDebouncer: NodeJS.Timeout | null = null;
let dictionarySearchTerm = $state('');
let promptText = $state('bonjour');
let answerText = $state('hello');
let dictionarySearchResults: DictionarySearchResult[] = $state([]);
let correctAnswers = $derived(
answerText
.split(',')
.map((t) => t.trim())
.filter((t) => t.length)
);
$effect(() => {
if (dictionarySearchDebouncer) {
clearTimeout(dictionarySearchDebouncer);
}
dictionarySearchDebouncer = setTimeout(() => {
dictionarySearch({ langCode: 'fr', text: dictionarySearchTerm }).then((results) => {
dictionarySearchResults = results;
});
}, 500);
});
</script>
<div class="form-container">
<form class="form">
<div class="field">
<label for="target_word">French Word</label>
<input type="text" id="target_word" name="target_word" bind:value={dictionarySearchTerm} />
</div>
<div class="field">
<label for="prompt_text">Prompt Text</label>
<input type="text" id="prompt_text" name="prompt_text" bind:value={promptText} />
</div>
<div class="field">
<label for="answer_text">Answers (comma separated)</label>
<input type="text" id="answer_text" name="answer_text" bind:value={answerText} />
</div>
</form>
</div>
<Flashcard {promptText} {correctAnswers} />
<style>
.form-container {
max-width: 500px;
}
</style>

291
tests/test_adventures.py Normal file
View 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.")