Compare commits
No commits in common. "8b687e973733a410bff0f8c40325b44fc49770f8" and "fb4ab69295f01a27e058bdc3c2ee154c01417074" have entirely different histories.
8b687e9737
...
fb4ab69295
24 changed files with 6 additions and 4221 deletions
2
Makefile
2
Makefile
|
|
@ -1,7 +1,7 @@
|
|||
.PHONY: down build up logs shell lock migrate migration import-dictionary
|
||||
|
||||
build:
|
||||
docker compose build --no-cache
|
||||
docker compose build
|
||||
|
||||
up:
|
||||
docker compose up -d
|
||||
|
|
|
|||
161
api/CLAUDE.md
161
api/CLAUDE.md
|
|
@ -1,161 +0,0 @@
|
|||
# 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` |
|
||||
|
|
@ -12,7 +12,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -1,208 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -19,7 +19,6 @@ class Settings(BaseSettings):
|
|||
storage_access_key: str
|
||||
storage_secret_key: str
|
||||
storage_bucket: str = "langlearn"
|
||||
stub_generation: bool = False
|
||||
|
||||
model_config = {"env_file": ".env"}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
"""
|
||||
Pure functions that translate adventure domain objects into LLM inputs and
|
||||
parse LLM outputs back into domain values.
|
||||
|
||||
Nothing in this module makes network calls or holds state. The service layer
|
||||
loads the data; these functions do the translation.
|
||||
"""
|
||||
import re
|
||||
|
||||
from ...domain.models.adventure import AdventureEntry, AdventureEntryPossibleChoice
|
||||
|
||||
|
||||
def build_entry_system_prompt(
|
||||
language_name: str,
|
||||
competency: str,
|
||||
max_entry_count: int,
|
||||
min_length: int,
|
||||
max_length: int,
|
||||
) -> str:
|
||||
halfway = max(1, max_entry_count // 2)
|
||||
return (
|
||||
f"You are an experienced tabletop game master running a single-player one-shot campaign "
|
||||
f"in a \"choose your own adventure\" format.\n\n"
|
||||
f"You are helping the player learn {language_name}. Your writing respects their "
|
||||
f"intelligence, avoids too many clichés, delivers satisfying plot beats, and reads naturally.\n\n"
|
||||
f"The session is {max_entry_count} turns. Each turn: you write a story passage, then offer "
|
||||
f"4 numbered choices. The player replies with their choice; you continue accordingly. "
|
||||
f"By turn {max_entry_count} there needs to be a clear end. As the player's choices reveal "
|
||||
f"their character, weave those details back into the story. "
|
||||
f"Don't railroad them until at least turn {halfway}.\n\n"
|
||||
f"Rules:\n"
|
||||
f"- Write entirely in {language_name} at {competency} level on the CEFR scale. "
|
||||
f"No markdown — plaintext only.\n"
|
||||
f"- Your response MUST be in exactly three parts, each separated by a line containing "
|
||||
f"only \"-----\".\n"
|
||||
f"- Part 1: the story entry, {min_length}–{max_length} words, speaking directly to the player.\n"
|
||||
f"- Part 2: exactly 4 numbered player options, one per line, labelled \"1.\", \"2.\", \"3.\", \"4.\".\n"
|
||||
f"- Part 3: GM notes to your future self (hidden from the player). "
|
||||
f"If no notes, write \"no notes\".\n"
|
||||
f"- Your first message must establish: who the player is, the setting, and the broad direction.\n"
|
||||
f"- No sexual content or graphic violence. Romance, threat, and adventure are fine (12-certificate)."
|
||||
)
|
||||
|
||||
|
||||
def build_title_system_prompt() -> str:
|
||||
return (
|
||||
"You are a creative writing assistant. Given the opening passage of a choose-your-own-adventure "
|
||||
"story, generate a short title and a one-sentence description for it.\n\n"
|
||||
"Respond with exactly two lines of plain text:\n"
|
||||
"Line 1: the title (max 60 characters, no quotes or labels)\n"
|
||||
"Line 2: the description (max 200 characters, no quotes or labels)"
|
||||
)
|
||||
|
||||
|
||||
def build_initial_user_message(
|
||||
genres: list[str],
|
||||
setting: list[str],
|
||||
vibes: list[str],
|
||||
protagonist: list[str],
|
||||
) -> str:
|
||||
return (
|
||||
"Please begin the adventure with the following details:\n"
|
||||
f"- Genre: {', '.join(genres)}\n"
|
||||
f"- Setting: {', '.join(setting)}\n"
|
||||
f"- Vibes: {', '.join(vibes)}\n"
|
||||
f"- Protagonist: {', '.join(protagonist)}"
|
||||
)
|
||||
|
||||
|
||||
def build_title_user_message(
|
||||
first_entry_text: str,
|
||||
language_name: str,
|
||||
genres: list[str],
|
||||
) -> str:
|
||||
return (
|
||||
f"This is the opening passage of a {', '.join(genres)} adventure written in {language_name}:\n\n"
|
||||
f"{first_entry_text}"
|
||||
)
|
||||
|
||||
|
||||
def reconstruct_assistant_message(
|
||||
entry: AdventureEntry,
|
||||
choices: list[AdventureEntryPossibleChoice],
|
||||
) -> str:
|
||||
"""Rebuild the original three-part LLM response from stored entry data."""
|
||||
options_block = "\n".join(
|
||||
f"{c.label}. {c.text}" for c in sorted(choices, key=lambda c: c.index)
|
||||
)
|
||||
gm_block = entry.gamemaster_notes or "no notes"
|
||||
return f"{entry.story_text}\n-----\n{options_block}\n-----\n{gm_block}"
|
||||
|
||||
|
||||
def build_conversation_messages(
|
||||
genres: list[str],
|
||||
setting: list[str],
|
||||
vibes: list[str],
|
||||
protagonist: list[str],
|
||||
prior_entries: list[tuple[AdventureEntry, list[AdventureEntryPossibleChoice], str | None]],
|
||||
) -> list[dict]:
|
||||
"""Build the full messages array for an Anthropic API call.
|
||||
|
||||
prior_entries is a list of (entry, choices_for_that_entry, chosen_label_or_None).
|
||||
The chosen label is the label of the option the player picked to advance past that entry.
|
||||
For the most recent completed entry it will be None (no choice made yet).
|
||||
"""
|
||||
messages: list[dict] = [
|
||||
{"role": "user", "content": build_initial_user_message(genres, setting, vibes, protagonist)}
|
||||
]
|
||||
for entry, choices, chosen_label in prior_entries:
|
||||
messages.append(
|
||||
{"role": "assistant", "content": reconstruct_assistant_message(entry, choices)}
|
||||
)
|
||||
if chosen_label is not None:
|
||||
messages.append({"role": "user", "content": chosen_label})
|
||||
return messages
|
||||
|
||||
|
||||
def parse_entry_response(text: str) -> tuple[str, list[tuple[str, str]], str]:
|
||||
"""Parse a three-part LLM entry response.
|
||||
|
||||
Returns (story_text, choices, gm_notes).
|
||||
choices is a list of (label, text) pairs e.g. [("1", "Go into the house"), ...].
|
||||
Raises ValueError if the format cannot be parsed.
|
||||
"""
|
||||
parts = text.split("\n-----\n")
|
||||
if len(parts) < 3:
|
||||
parts = text.split("-----\n")
|
||||
if len(parts) < 3:
|
||||
raise ValueError(f"LLM response has {len(parts)} section(s); expected 3")
|
||||
|
||||
story_text = parts[0].strip()
|
||||
options_raw = parts[1].strip()
|
||||
gm_notes = "\n-----\n".join(parts[2:]).strip()
|
||||
|
||||
choices: list[tuple[str, str]] = []
|
||||
for line in options_raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
m = re.match(r"^(\d+)[.)]\s+(.+)$", line)
|
||||
if m:
|
||||
choices.append((m.group(1), m.group(2).strip()))
|
||||
|
||||
if not choices:
|
||||
raise ValueError("No choices parsed from LLM response options section")
|
||||
|
||||
return story_text, choices, gm_notes
|
||||
|
||||
|
||||
def parse_title_response(text: str) -> tuple[str, str]:
|
||||
"""Parse a two-line title/description response.
|
||||
|
||||
Returns (title, description). Falls back gracefully if only one line is present.
|
||||
"""
|
||||
lines = [l.strip() for l in text.strip().splitlines() if l.strip()]
|
||||
title = lines[0][:60] if lines else "Untitled Adventure"
|
||||
description = lines[1][:200] if len(lines) > 1 else ""
|
||||
return title, description
|
||||
|
|
@ -41,35 +41,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -1,144 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,403 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,425 +0,0 @@
|
|||
import io
|
||||
import uuid
|
||||
import wave
|
||||
from functools import partial
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ...auth import verify_token
|
||||
from ...config import settings
|
||||
from ...domain.services.adventure_service import AdventureService
|
||||
from ...languages import SUPPORTED_LANGUAGES
|
||||
from ...outbound.anthropic.anthropic_client import AnthropicClient
|
||||
from ...outbound.deepl.deepl_client import DeepLClient
|
||||
from ...outbound.gemini.gemini_client import GeminiClient
|
||||
from ...outbound.postgres.database import AsyncSessionLocal, get_db
|
||||
from ...outbound.postgres.repositories.adventure_repository import (
|
||||
PostgresAdventureEntryAudioRepository,
|
||||
PostgresAdventureEntryChoiceRepository,
|
||||
PostgresAdventureEntryDecisionRepository,
|
||||
PostgresAdventureEntryRepository,
|
||||
PostgresAdventureEntryTranslationRepository,
|
||||
PostgresAdventureRepository,
|
||||
)
|
||||
from ... import worker
|
||||
|
||||
router = APIRouter(prefix="/adventures", tags=["adventures"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub clients for the test environment (STUB_GENERATION=true)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_STUB_ENTRY_RESPONSE = (
|
||||
"Vous vous retrouvez dans une ruelle sombre de Paris. "
|
||||
"Une silhouette mystérieuse s'approche lentement.\n"
|
||||
"-----\n"
|
||||
"1. Suivez la silhouette dans l'obscurité\n"
|
||||
"2. Restez dans l'ombre et observez\n"
|
||||
"3. Demandez de l'aide à voix haute\n"
|
||||
"4. Courez vers la lumière au bout de la ruelle\n"
|
||||
"-----\n"
|
||||
"no notes"
|
||||
)
|
||||
_STUB_TITLE_RESPONSE = (
|
||||
"La Nuit Parisienne\n"
|
||||
"Une aventure mystérieuse dans les rues sombres de Paris."
|
||||
)
|
||||
|
||||
|
||||
class _StubAnthropicClient:
|
||||
async def complete(
|
||||
self,
|
||||
system_prompt: str,
|
||||
messages: list[dict],
|
||||
model: str = "claude-sonnet-4-6",
|
||||
max_tokens: int = 2048,
|
||||
) -> tuple[str, dict]:
|
||||
usage = {"provider": "stub", "model": "stub", "input_tokens": 0, "output_tokens": 0}
|
||||
if "game master" in system_prompt.lower():
|
||||
return _STUB_ENTRY_RESPONSE, usage
|
||||
return _STUB_TITLE_RESPONSE, usage
|
||||
|
||||
|
||||
class _StubDeepLClient:
|
||||
def can_translate_to(self, lang: str) -> bool:
|
||||
return True
|
||||
|
||||
async def translate(self, text: str, to_language: str, context: str | None = None) -> str:
|
||||
return f"[STUB] {text[:120]}"
|
||||
|
||||
|
||||
class _StubGeminiClient:
|
||||
def get_voice_by_language(self, lang: str) -> str:
|
||||
return "Stub"
|
||||
|
||||
async def generate_audio(self, text: str, voice: str) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(2)
|
||||
wf.setframerate(24000)
|
||||
wf.writeframes(b"\x00" * 480)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_service(db: AsyncSession) -> AdventureService:
|
||||
if settings.stub_generation:
|
||||
anthropic = _StubAnthropicClient() # type: ignore[assignment]
|
||||
deepl = _StubDeepLClient() # type: ignore[assignment]
|
||||
gemini = _StubGeminiClient() # type: ignore[assignment]
|
||||
else:
|
||||
anthropic = AnthropicClient.new(settings.anthropic_api_key)
|
||||
deepl = DeepLClient(settings.deepl_api_key)
|
||||
gemini = GeminiClient(settings.gemini_api_key)
|
||||
|
||||
return AdventureService(
|
||||
adventure_repo=PostgresAdventureRepository(db),
|
||||
entry_repo=PostgresAdventureEntryRepository(db),
|
||||
choice_repo=PostgresAdventureEntryChoiceRepository(db),
|
||||
decision_repo=PostgresAdventureEntryDecisionRepository(db),
|
||||
translation_repo=PostgresAdventureEntryTranslationRepository(db),
|
||||
audio_repo=PostgresAdventureEntryAudioRepository(db),
|
||||
anthropic_client=anthropic,
|
||||
deepl_client=deepl,
|
||||
gemini_client=gemini,
|
||||
)
|
||||
|
||||
|
||||
async def _run_entry_pipeline_task(
|
||||
adventure_id: uuid.UUID, entry_id: uuid.UUID
|
||||
) -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
await _make_service(db).run_entry_pipeline(adventure_id, entry_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CreateAdventureRequest(BaseModel):
|
||||
language: str
|
||||
source_language: str
|
||||
competencies: list[str]
|
||||
genres: list[str]
|
||||
setting: list[str]
|
||||
vibes: list[str]
|
||||
protagonist: list[str]
|
||||
max_entry_count: int = 6
|
||||
|
||||
|
||||
class AdventureResponse(BaseModel):
|
||||
id: str
|
||||
user_id: str
|
||||
status: str
|
||||
language: str
|
||||
source_language: str
|
||||
competencies: list[str]
|
||||
max_entry_count: int
|
||||
title: str
|
||||
description: str | None
|
||||
genres: list[str]
|
||||
setting: list[str]
|
||||
vibes: list[str]
|
||||
protagonist: list[str]
|
||||
created_at: str
|
||||
|
||||
|
||||
class CreateDecisionRequest(BaseModel):
|
||||
choice_id: str
|
||||
|
||||
|
||||
class DecisionResponse(BaseModel):
|
||||
id: str
|
||||
choice_id: str
|
||||
user_id: str
|
||||
created_at: str
|
||||
|
||||
|
||||
class EntryResponse(BaseModel):
|
||||
id: str
|
||||
adventure_id: str
|
||||
generated_from_choice_id: str | None
|
||||
status: str
|
||||
entry_index: int
|
||||
story_text: str | None
|
||||
created_at: str
|
||||
|
||||
|
||||
class ChoiceResponse(BaseModel):
|
||||
id: str
|
||||
index: int
|
||||
label: str
|
||||
text: str
|
||||
|
||||
|
||||
class EntryDetailResponse(BaseModel):
|
||||
id: str
|
||||
adventure_id: str
|
||||
generated_from_choice_id: str | None
|
||||
status: str
|
||||
entry_index: int
|
||||
story_text: str | None
|
||||
created_at: str
|
||||
choices: list[ChoiceResponse]
|
||||
translation: str | None
|
||||
audio_file_name: str | None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _to_adventure_response(adventure) -> AdventureResponse:
|
||||
return AdventureResponse(
|
||||
id=adventure.id,
|
||||
user_id=adventure.user_id,
|
||||
status=adventure.status,
|
||||
language=adventure.language,
|
||||
source_language=adventure.source_language,
|
||||
competencies=adventure.competencies,
|
||||
max_entry_count=adventure.max_entry_count,
|
||||
title=adventure.title,
|
||||
description=adventure.description,
|
||||
genres=adventure.genres,
|
||||
setting=adventure.setting,
|
||||
vibes=adventure.vibes,
|
||||
protagonist=adventure.protagonist,
|
||||
created_at=adventure.created_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
def _parse_adventure_id(adventure_id: str) -> uuid.UUID:
|
||||
try:
|
||||
return uuid.UUID(adventure_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid adventure_id")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("", response_model=AdventureResponse, status_code=201)
|
||||
async def create_adventure(
|
||||
body: CreateAdventureRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> AdventureResponse:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
|
||||
if body.language not in SUPPORTED_LANGUAGES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported language '{body.language}'. Supported: {list(SUPPORTED_LANGUAGES)}",
|
||||
)
|
||||
|
||||
deepl_client = DeepLClient(settings.deepl_api_key) if not settings.stub_generation else _StubDeepLClient() # type: ignore[assignment]
|
||||
if not deepl_client.can_translate_to(body.source_language):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot translate to source language '{body.source_language}'",
|
||||
)
|
||||
|
||||
adventure, first_entry = await _make_service(db).create_adventure_for_user(
|
||||
user_id=user_id,
|
||||
language=body.language,
|
||||
source_language=body.source_language,
|
||||
competencies=body.competencies,
|
||||
genres=body.genres,
|
||||
setting=body.setting,
|
||||
vibes=body.vibes,
|
||||
protagonist=body.protagonist,
|
||||
max_entry_count=body.max_entry_count,
|
||||
)
|
||||
await worker.enqueue(
|
||||
partial(_run_entry_pipeline_task, uuid.UUID(adventure.id), uuid.UUID(first_entry.id))
|
||||
)
|
||||
return _to_adventure_response(adventure)
|
||||
|
||||
|
||||
@router.get("", response_model=list[AdventureResponse])
|
||||
async def list_adventures(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> list[AdventureResponse]:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
adventures = await PostgresAdventureRepository(db).list_for_user(user_id)
|
||||
return [_to_adventure_response(a) for a in adventures]
|
||||
|
||||
|
||||
@router.get("/{adventure_id}", response_model=AdventureResponse)
|
||||
async def get_adventure(
|
||||
adventure_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> AdventureResponse:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
adventure = await PostgresAdventureRepository(db).get_by_id(_parse_adventure_id(adventure_id))
|
||||
if adventure is None or adventure.user_id != str(user_id):
|
||||
raise HTTPException(status_code=404, detail="Adventure not found")
|
||||
return _to_adventure_response(adventure)
|
||||
|
||||
|
||||
@router.delete("/{adventure_id}", status_code=204)
|
||||
async def delete_adventure(
|
||||
adventure_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> None:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
repo = PostgresAdventureRepository(db)
|
||||
adventure = await repo.get_by_id(_parse_adventure_id(adventure_id))
|
||||
if adventure is None or adventure.user_id != str(user_id):
|
||||
raise HTTPException(status_code=404, detail="Adventure not found")
|
||||
await repo.soft_delete(uuid.UUID(adventure.id))
|
||||
|
||||
|
||||
@router.post("/{adventure_id}/decisions", response_model=DecisionResponse, status_code=201)
|
||||
async def record_decision(
|
||||
adventure_id: str,
|
||||
body: CreateDecisionRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> DecisionResponse:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
|
||||
try:
|
||||
choice_id = uuid.UUID(body.choice_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid choice_id")
|
||||
|
||||
try:
|
||||
decision, next_entry = await _make_service(db).record_decision_and_prepare_next_entry(
|
||||
adventure_id=_parse_adventure_id(adventure_id),
|
||||
choice_id=choice_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
key = str(exc)
|
||||
if key == "adventure_not_found":
|
||||
raise HTTPException(status_code=404, detail="Adventure not found")
|
||||
if key == "adventure_not_active":
|
||||
raise HTTPException(status_code=409, detail="adventure_not_active")
|
||||
if key in ("choice_not_found", "choice_not_in_adventure"):
|
||||
raise HTTPException(status_code=404, detail="Choice not found")
|
||||
if key == "decision_already_made":
|
||||
raise HTTPException(status_code=409, detail="decision_already_made")
|
||||
raise HTTPException(status_code=400, detail=key)
|
||||
|
||||
await worker.enqueue(
|
||||
partial(
|
||||
_run_entry_pipeline_task,
|
||||
uuid.UUID(next_entry.adventure_id),
|
||||
uuid.UUID(next_entry.id),
|
||||
)
|
||||
)
|
||||
return DecisionResponse(
|
||||
id=decision.id,
|
||||
choice_id=decision.choice_id,
|
||||
user_id=decision.user_id,
|
||||
created_at=decision.created_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{adventure_id}/entries", response_model=list[EntryResponse])
|
||||
async def list_entries(
|
||||
adventure_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> list[EntryResponse]:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
adv_id = _parse_adventure_id(adventure_id)
|
||||
|
||||
adventure = await PostgresAdventureRepository(db).get_by_id(adv_id)
|
||||
if adventure is None or adventure.user_id != str(user_id):
|
||||
raise HTTPException(status_code=404, detail="Adventure not found")
|
||||
|
||||
entries = await PostgresAdventureEntryRepository(db).list_for_adventure(adv_id)
|
||||
return [
|
||||
EntryResponse(
|
||||
id=e.id,
|
||||
adventure_id=e.adventure_id,
|
||||
generated_from_choice_id=e.generated_from_choice_id,
|
||||
status=e.status,
|
||||
entry_index=e.entry_index,
|
||||
story_text=e.story_text,
|
||||
created_at=e.created_at.isoformat(),
|
||||
)
|
||||
for e in entries
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{adventure_id}/entries/{entry_id}", response_model=EntryDetailResponse)
|
||||
async def get_entry(
|
||||
adventure_id: str,
|
||||
entry_id: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
token_data: dict = Depends(verify_token),
|
||||
) -> EntryDetailResponse:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
adv_id = _parse_adventure_id(adventure_id)
|
||||
|
||||
adventure = await PostgresAdventureRepository(db).get_by_id(adv_id)
|
||||
if adventure is None or adventure.user_id != str(user_id):
|
||||
raise HTTPException(status_code=404, detail="Adventure not found")
|
||||
|
||||
try:
|
||||
eid = uuid.UUID(entry_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid entry_id")
|
||||
|
||||
entry = await PostgresAdventureEntryRepository(db).get_by_id(eid)
|
||||
if entry is None or entry.adventure_id != str(adv_id):
|
||||
raise HTTPException(status_code=404, detail="Entry not found")
|
||||
|
||||
choices = await PostgresAdventureEntryChoiceRepository(db).list_for_entry(eid)
|
||||
translation = await PostgresAdventureEntryTranslationRepository(db).get_for_entry(
|
||||
entry_id=eid,
|
||||
component_type="story_text",
|
||||
target_language=adventure.source_language,
|
||||
)
|
||||
audio = await PostgresAdventureEntryAudioRepository(db).get_for_entry(
|
||||
entry_id=eid, component_type="story_text"
|
||||
)
|
||||
|
||||
return EntryDetailResponse(
|
||||
id=entry.id,
|
||||
adventure_id=entry.adventure_id,
|
||||
generated_from_choice_id=entry.generated_from_choice_id,
|
||||
status=entry.status,
|
||||
entry_index=entry.entry_index,
|
||||
story_text=entry.story_text,
|
||||
created_at=entry.created_at.isoformat(),
|
||||
choices=[
|
||||
ChoiceResponse(id=c.id, index=c.index, label=c.label, text=c.text) for c in choices
|
||||
],
|
||||
translation=translation.translated_text if translation else None,
|
||||
audio_file_name=audio.file_name if audio else None,
|
||||
)
|
||||
|
|
@ -10,7 +10,6 @@ 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
|
||||
|
||||
|
|
@ -28,4 +27,3 @@ 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)
|
||||
|
|
|
|||
|
|
@ -1,52 +0,0 @@
|
|||
# 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
|
||||
}
|
||||
```
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
# 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, 600–700 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"
|
||||
}
|
||||
```
|
||||
|
|
@ -1,31 +1,5 @@
|
|||
# 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
|
|
@ -44,7 +44,6 @@ services:
|
|||
STORAGE_ACCESS_KEY: langlearn_test
|
||||
STORAGE_SECRET_KEY: testpassword123
|
||||
STORAGE_BUCKET: langlearn-test
|
||||
STUB_GENERATION: "true"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -42,20 +42,6 @@
|
|||
--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;
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,27 +1,17 @@
|
|||
import { query, getRequestEvent } from '$app/server';
|
||||
import * as v from 'valibot';
|
||||
import {
|
||||
searchWordformsPrefixApiDictionarySearchGet,
|
||||
searchWordformsApiDictionarySearchGet,
|
||||
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 }): Promise<DictionarySearchResult[]> => {
|
||||
async ({ langCode, text }) => {
|
||||
const { cookies } = getRequestEvent();
|
||||
const trimmed = text.trim();
|
||||
|
||||
|
|
@ -35,26 +25,14 @@ export const dictionarySearch = query(
|
|||
query: { lang_code: langCode, text }
|
||||
});
|
||||
|
||||
return (data ?? []).map(({ lemma, senses }) => ({
|
||||
lemma: { text: lemma.headword },
|
||||
senses: senses.map(({ gloss, id }) => ({
|
||||
text: gloss,
|
||||
id
|
||||
}))
|
||||
}));
|
||||
return data;
|
||||
} else {
|
||||
const { data } = await searchWordformsPrefixApiDictionarySearchGet({
|
||||
const { data } = await searchWordformsApiDictionarySearchGet({
|
||||
headers: { Authorization: `Bearer ${cookies.get(COOKIE_NAME_AUTH_TOKEN)}` },
|
||||
query: { lang_code: langCode, text }
|
||||
});
|
||||
|
||||
return (data ?? []).map(({ lemma, senses }) => ({
|
||||
lemma: { text: lemma.headword },
|
||||
senses: senses.map(({ gloss, id }) => ({
|
||||
text: gloss,
|
||||
id
|
||||
}))
|
||||
}));
|
||||
return data;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
<script>
|
||||
import FlashcardForm from './FlashcardForm.svelte';
|
||||
</script>
|
||||
|
||||
<h1>New Flashcard</h1>
|
||||
<FlashcardForm />
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
<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>
|
||||
|
|
@ -1,291 +0,0 @@
|
|||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _register_and_login(client: httpx.Client, email: str, password: str = "password123") -> str:
|
||||
client.post("/auth/register", json={"email": email, "password": password})
|
||||
resp = client.post("/auth/login", json={"email": email, "password": password})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
def _auth_client(client: httpx.Client, email: str) -> httpx.Client:
|
||||
token = _register_and_login(client, email)
|
||||
client.headers["Authorization"] = f"Bearer {token}"
|
||||
return client
|
||||
|
||||
|
||||
def _wait_for_adventure_status(
|
||||
client: httpx.Client,
|
||||
adventure_id: str,
|
||||
expected_status: str,
|
||||
timeout: int = 30,
|
||||
) -> dict:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
resp = client.get(f"/api/adventures/{adventure_id}")
|
||||
assert resp.status_code == 200, resp.text
|
||||
if resp.json()["status"] == expected_status:
|
||||
return resp.json()
|
||||
time.sleep(0.5)
|
||||
raise TimeoutError(
|
||||
f"Adventure {adventure_id} did not reach '{expected_status}' within {timeout}s. "
|
||||
f"Last status: {client.get(f'/api/adventures/{adventure_id}').json().get('status')}"
|
||||
)
|
||||
|
||||
|
||||
_DEFAULT_ADVENTURE_BODY = {
|
||||
"language": "fr",
|
||||
"source_language": "en",
|
||||
"competencies": ["B1"],
|
||||
"genres": ["crime fiction"],
|
||||
"setting": ["Paris", "city"],
|
||||
"vibes": ["dark"],
|
||||
"protagonist": ["male", "late-teens"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_client(client: httpx.Client) -> httpx.Client:
|
||||
email = f"adventure-user-{uuid.uuid4()}@example.com"
|
||||
return _auth_client(client, email)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def second_user_client(client: httpx.Client) -> httpx.Client:
|
||||
email = f"adventure-user2-{uuid.uuid4()}@example.com"
|
||||
return _auth_client(client, email)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 1: Adventure creation and first-entry pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_create_adventure_generates_first_entry(user_client: httpx.Client) -> None:
|
||||
resp = user_client.post("/api/adventures", json=_DEFAULT_ADVENTURE_BODY)
|
||||
|
||||
assert resp.status_code == 201
|
||||
body = resp.json()
|
||||
adventure_id = body["id"]
|
||||
assert body["status"] == "awaiting_first_entry"
|
||||
assert body["title"] == "Untitled adventure"
|
||||
|
||||
adventure = _wait_for_adventure_status(user_client, adventure_id, "active")
|
||||
assert adventure["title"] != "Untitled adventure"
|
||||
assert adventure["description"] is not None
|
||||
|
||||
entries_resp = user_client.get(f"/api/adventures/{adventure_id}/entries")
|
||||
assert entries_resp.status_code == 200
|
||||
entries = entries_resp.json()
|
||||
assert len(entries) == 1
|
||||
assert entries[0]["status"] == "complete"
|
||||
assert entries[0]["entry_index"] == 0
|
||||
assert entries[0]["story_text"] is not None
|
||||
|
||||
detail_resp = user_client.get(f"/api/adventures/{adventure_id}/entries/{entries[0]['id']}")
|
||||
assert detail_resp.status_code == 200
|
||||
detail = detail_resp.json()
|
||||
assert len(detail["choices"]) == 4
|
||||
assert detail["translation"] is not None
|
||||
assert detail["audio_file_name"] is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 2: Recording a decision generates the next entry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_record_decision_generates_next_entry(user_client: httpx.Client) -> None:
|
||||
resp = user_client.post("/api/adventures", json=_DEFAULT_ADVENTURE_BODY)
|
||||
adventure_id = resp.json()["id"]
|
||||
_wait_for_adventure_status(user_client, adventure_id, "active")
|
||||
|
||||
entries = user_client.get(f"/api/adventures/{adventure_id}/entries").json()
|
||||
detail = user_client.get(
|
||||
f"/api/adventures/{adventure_id}/entries/{entries[0]['id']}"
|
||||
).json()
|
||||
choice_id = detail["choices"][0]["id"]
|
||||
|
||||
decision_resp = user_client.post(
|
||||
f"/api/adventures/{adventure_id}/decisions", json={"choice_id": choice_id}
|
||||
)
|
||||
assert decision_resp.status_code == 201
|
||||
assert decision_resp.json()["choice_id"] == choice_id
|
||||
|
||||
_wait_for_adventure_status(user_client, adventure_id, "active")
|
||||
|
||||
entries = user_client.get(f"/api/adventures/{adventure_id}/entries").json()
|
||||
assert len(entries) == 2
|
||||
second = next(e for e in entries if e["entry_index"] == 1)
|
||||
assert second["status"] == "complete"
|
||||
assert second["generated_from_choice_id"] == choice_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 3: Adventure completes after max_entry_count entries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_adventure_completes_at_max_entries(user_client: httpx.Client) -> None:
|
||||
body = {**_DEFAULT_ADVENTURE_BODY, "max_entry_count": 2}
|
||||
resp = user_client.post("/api/adventures", json=body)
|
||||
adventure_id = resp.json()["id"]
|
||||
_wait_for_adventure_status(user_client, adventure_id, "active")
|
||||
|
||||
entries = user_client.get(f"/api/adventures/{adventure_id}/entries").json()
|
||||
detail = user_client.get(
|
||||
f"/api/adventures/{adventure_id}/entries/{entries[0]['id']}"
|
||||
).json()
|
||||
choice_id = detail["choices"][0]["id"]
|
||||
|
||||
user_client.post(
|
||||
f"/api/adventures/{adventure_id}/decisions", json={"choice_id": choice_id}
|
||||
)
|
||||
adventure = _wait_for_adventure_status(user_client, adventure_id, "complete")
|
||||
assert adventure["status"] == "complete"
|
||||
|
||||
entries = user_client.get(f"/api/adventures/{adventure_id}/entries").json()
|
||||
assert len(entries) == 2
|
||||
|
||||
final_detail = user_client.get(
|
||||
f"/api/adventures/{adventure_id}/entries/{entries[1]['id']}"
|
||||
).json()
|
||||
assert final_detail["choices"] == []
|
||||
|
||||
# Decision on a complete adventure returns 409
|
||||
extra = user_client.post(
|
||||
f"/api/adventures/{adventure_id}/decisions", json={"choice_id": choice_id}
|
||||
)
|
||||
assert extra.status_code == 409
|
||||
assert "not_active" in extra.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4: Double-decision on the same entry is rejected
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_cannot_make_second_decision_on_same_entry(user_client: httpx.Client) -> None:
|
||||
resp = user_client.post("/api/adventures", json=_DEFAULT_ADVENTURE_BODY)
|
||||
adventure_id = resp.json()["id"]
|
||||
_wait_for_adventure_status(user_client, adventure_id, "active")
|
||||
|
||||
entries = user_client.get(f"/api/adventures/{adventure_id}/entries").json()
|
||||
detail = user_client.get(
|
||||
f"/api/adventures/{adventure_id}/entries/{entries[0]['id']}"
|
||||
).json()
|
||||
choice_id = detail["choices"][0]["id"]
|
||||
other_choice_id = detail["choices"][1]["id"]
|
||||
|
||||
first = user_client.post(
|
||||
f"/api/adventures/{adventure_id}/decisions", json={"choice_id": choice_id}
|
||||
)
|
||||
assert first.status_code == 201
|
||||
|
||||
second = user_client.post(
|
||||
f"/api/adventures/{adventure_id}/decisions", json={"choice_id": other_choice_id}
|
||||
)
|
||||
assert second.status_code == 409
|
||||
assert "decision_already_made" in second.json()["detail"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 5: User isolation — one user cannot see or interact with another's adventure
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_user_cannot_access_another_users_adventure(
|
||||
user_client: httpx.Client, second_user_client: httpx.Client
|
||||
) -> None:
|
||||
resp = user_client.post("/api/adventures", json=_DEFAULT_ADVENTURE_BODY)
|
||||
adventure_id = resp.json()["id"]
|
||||
|
||||
assert second_user_client.get(f"/api/adventures/{adventure_id}").status_code == 404
|
||||
assert second_user_client.get(f"/api/adventures/{adventure_id}/entries").status_code == 404
|
||||
|
||||
decision_resp = second_user_client.post(
|
||||
f"/api/adventures/{adventure_id}/decisions",
|
||||
json={"choice_id": str(uuid.uuid4())},
|
||||
)
|
||||
assert decision_resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 6: Soft-delete removes adventure from list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_soft_delete_hides_adventure(user_client: httpx.Client) -> None:
|
||||
resp = user_client.post("/api/adventures", json=_DEFAULT_ADVENTURE_BODY)
|
||||
adventure_id = resp.json()["id"]
|
||||
|
||||
delete_resp = user_client.delete(f"/api/adventures/{adventure_id}")
|
||||
assert delete_resp.status_code == 204
|
||||
|
||||
# Should no longer appear in the list
|
||||
adventures = user_client.get("/api/adventures").json()
|
||||
assert not any(a["id"] == adventure_id for a in adventures)
|
||||
|
||||
# Direct GET also 404s after deletion
|
||||
assert user_client.get(f"/api/adventures/{adventure_id}").status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit-level: LLM response parser (no Docker / network required)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _parse(text: str):
|
||||
"""Local copy of the parser for isolated unit testing."""
|
||||
import re
|
||||
|
||||
parts = text.split("\n-----\n")
|
||||
if len(parts) < 3:
|
||||
parts = text.split("-----\n")
|
||||
if len(parts) < 3:
|
||||
raise ValueError(f"LLM response has {len(parts)} section(s); expected 3")
|
||||
|
||||
story_text = parts[0].strip()
|
||||
options_raw = parts[1].strip()
|
||||
gm_notes = "\n-----\n".join(parts[2:]).strip()
|
||||
|
||||
choices = []
|
||||
for line in options_raw.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
m = re.match(r"^(\d+)[.)]\s+(.+)$", line)
|
||||
if m:
|
||||
choices.append((m.group(1), m.group(2).strip()))
|
||||
|
||||
if not choices:
|
||||
raise ValueError("No choices parsed from LLM response options section")
|
||||
|
||||
return story_text, choices, gm_notes
|
||||
|
||||
|
||||
def test_parse_valid_three_section_response() -> None:
|
||||
text = (
|
||||
"Story text here.\n-----\n"
|
||||
"1. Option one\n2. Option two\n3. Option three\n4. Option four\n"
|
||||
"-----\nno notes"
|
||||
)
|
||||
story, choices, notes = _parse(text)
|
||||
assert story == "Story text here."
|
||||
assert len(choices) == 4
|
||||
assert choices[0] == ("1", "Option one")
|
||||
assert choices[3] == ("4", "Option four")
|
||||
assert notes == "no notes"
|
||||
|
||||
|
||||
def test_parse_with_parenthesis_delimiters() -> None:
|
||||
text = "Story.\n-----\n1) First\n2) Second\n3) Third\n4) Fourth\n-----\nGM note."
|
||||
_, choices, notes = _parse(text)
|
||||
assert len(choices) == 4
|
||||
assert choices[2] == ("3", "Third")
|
||||
assert notes == "GM note."
|
||||
|
||||
|
||||
def test_parse_missing_sections_raises() -> None:
|
||||
with pytest.raises(ValueError, match="section"):
|
||||
_parse("Only one section here, no separators at all.")
|
||||
Loading…
Reference in a new issue