Compare commits

...

3 commits

21 changed files with 1117 additions and 399 deletions

View file

@ -0,0 +1,31 @@
"""add story_text_linguistic_data and pipeline_timing to adventure entry
Revision ID: 0018
Revises: 0017
Create Date: 2026-05-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "0018"
down_revision: Union[str, None] = "0017"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"choose_your_own_adventure_entry",
sa.Column("story_text_linguistic_data", postgresql.JSONB(), nullable=True),
)
op.add_column(
"choose_your_own_adventure_entry",
sa.Column("pipeline_timing", postgresql.JSONB(), nullable=True),
)
def downgrade() -> None:
op.drop_column("choose_your_own_adventure_entry", "pipeline_timing")
op.drop_column("choose_your_own_adventure_entry", "story_text_linguistic_data")

View file

@ -33,6 +33,8 @@ class AdventureEntry:
story_text: str | None
gamemaster_notes: str | None
llm_data: dict | None
story_text_linguistic_data: dict | None
pipeline_timing: dict | None
created_at: datetime

View file

@ -1,4 +1,6 @@
import asyncio
import logging
import time
import uuid
from ...languages import SUPPORTED_LANGUAGES
@ -21,16 +23,23 @@ from ...outbound.postgres.repositories.adventure_repository import (
PostgresAdventureEntryTranslationRepository,
PostgresAdventureRepository,
)
from ...outbound.spacy.spacy_client import SpacyClient
from ...storage import upload_audio
from ..models.adventure import (
Adventure,
AdventureEntry,
AdventureEntryPossibleChoice,
AdventureEntryPossibleChoiceDecision,
)
logger = logging.getLogger(__name__)
def _base_language(lang: str) -> str:
"""Normalise a language tag like 'en-US' or 'EN' to a bare ISO 639-1 code."""
return lang.split("-")[0].split("_")[0].lower()
class AdventureService:
def __init__(
self,
@ -43,6 +52,7 @@ class AdventureService:
anthropic_client: AnthropicClient,
deepl_client: DeepLClient,
gemini_client: GeminiClient,
spacy_client: SpacyClient,
) -> None:
self.adventure_repo = adventure_repo
self.entry_repo = entry_repo
@ -53,6 +63,7 @@ class AdventureService:
self.anthropic_client = anthropic_client
self.deepl_client = deepl_client
self.gemini_client = gemini_client
self.spacy_client = spacy_client
async def create_adventure_for_user(
self,
@ -148,8 +159,8 @@ class AdventureService:
) -> 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.
Sequence: LLM generation parse persist NLP + per-sentence translation
TTS adventure title (first entry only) update adventure status.
On any error the entry and adventure are marked 'error'.
"""
@ -158,20 +169,21 @@ class AdventureService:
assert adventure is not None, f"Adventure {adventure_id} not found"
all_entries = await self.entry_repo.list_for_adventure(adventure_id)
all_decisions = [await self.decision_repo.get_for_entry_and_user(entry_id=uuid.UUID(e.id), user_id=user_id) for e in all_entries]
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(
prior_entries_with_possible_choices = await self._load_possible_choices_for_entries(
all_entries=[
e for e in all_entries if e.entry_index < current_entry.entry_index
],
user_id=user_id,
)
prior_decisions = await self.decision_repo.list_for_adventure_and_user(
adventure_id=adventure_id, user_id=user_id
)
language_name = SUPPORTED_LANGUAGES.get(
adventure.language, adventure.language
)
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,
@ -185,15 +197,17 @@ class AdventureService:
setting=adventure.setting,
vibes=adventure.vibes,
protagonist=adventure.protagonist,
prior_entries=prior_entries,
prior_decisions=all_decisions,
prior_entries_with_choices=prior_entries_with_possible_choices,
)
# ── LLM generation ──────────────────────────────────────────────
t0 = time.monotonic()
raw_text, usage_dict = await self.anthropic_client.complete(
system_prompt=system_prompt,
messages=messages,
max_tokens=2048,
)
timing_text_generation = time.monotonic() - t0
story_text, choices_parsed, gm_notes = parse_entry_response(raw_text)
@ -214,21 +228,96 @@ class AdventureService:
],
)
translated = await self.deepl_client.translate(
story_text, adventure.source_language
# ── Per-sentence NLP + translation ───────────────────────────────
target_lang = _base_language(adventure.language)
source_lang = _base_language(adventure.source_language)
timing_nlp = 0.0
timing_translations = 0.0
paragraphs = [p.strip() for p in story_text.split("\n\n") if p.strip()]
linguistic_paragraphs = []
for para_idx, paragraph_text in enumerate(paragraphs):
t0 = time.monotonic()
target_nlp = await asyncio.to_thread(
self.spacy_client.get_parts_of_speech, paragraph_text, target_lang
)
timing_nlp += time.monotonic() - t0
linguistic_sentences = []
translated_sentence_texts = []
for sent_idx, target_sent in enumerate(target_nlp["sentences"]):
t0 = time.monotonic()
translated_sentence = await self.deepl_client.translate(
target_sent["text"], adventure.source_language
)
timing_translations += time.monotonic() - t0
t0 = time.monotonic()
source_nlp = await asyncio.to_thread(
self.spacy_client.get_parts_of_speech,
translated_sentence,
source_lang,
)
timing_nlp += time.monotonic() - t0
source_tokens = (
source_nlp["sentences"][0]["tokens"]
if source_nlp["sentences"]
else []
)
translated_sentence_texts.append(translated_sentence)
linguistic_sentences.append(
{
"index": sent_idx,
"source_text": translated_sentence,
"target_text": target_sent["text"],
"source_tokens": source_tokens,
"target_tokens": target_sent["tokens"],
}
)
translated_paragraph = " ".join(translated_sentence_texts)
linguistic_paragraphs.append(
{
"index": para_idx,
"source_text": translated_paragraph,
"target_text": paragraph_text,
"sentences": linguistic_sentences,
}
)
story_text_linguistic_data = {
"source_language": source_lang,
"target_language": target_lang,
"paragraphs": linguistic_paragraphs,
}
full_translated_text = "\n\n".join(
p["source_text"] for p in linguistic_paragraphs
)
await self.translation_repo.create(
entry_id=entry_id,
component_type="story_text",
target_language=adventure.source_language,
translated_text=translated,
translated_text=full_translated_text,
)
# ── TTS ──────────────────────────────────────────────────────────
t0 = time.monotonic()
voice = self.gemini_client.get_voice_by_language(adventure.language)
story_text_with_tag = "[like a dungeons and dragons gamemaster] " + story_text
wav_bytes = await self.gemini_client.generate_audio(story_text_with_tag, voice)
timing_tts = time.monotonic() - t0
# ── File upload ───────────────────────────────────────────────────
t0 = time.monotonic()
audio_key = f"adventure-audio/{entry_id}.wav"
upload_audio(audio_key, wav_bytes)
timing_file_uploading = time.monotonic() - t0
await self.audio_repo.create(
entry_id=entry_id,
component_type="story_text",
@ -237,10 +326,27 @@ class AdventureService:
file_name=audio_key,
)
# ── Persist linguistic data + timing ─────────────────────────────
pipeline_timing = {
"durations": {
"text_generation": round(timing_text_generation, 1),
"translations_total": round(timing_translations, 1),
"nlp_total": round(timing_nlp, 1),
"tts": round(timing_tts, 1),
"file_uploading": round(timing_file_uploading, 1),
}
}
await self.entry_repo.update_linguistic_data(
entry_id=entry_id,
story_text_linguistic_data=story_text_linguistic_data,
pipeline_timing=pipeline_timing,
)
# ── Adventure title (first entry only) ────────────────────────────
if is_first_entry:
title_system = build_title_system_prompt()
title_user = build_title_user_message(
story_text, language_name, adventure.genres
story_text, language_name, adventure.genres, gm_notes
)
title_raw, _ = await self.anthropic_client.complete(
system_prompt=title_system,
@ -267,32 +373,21 @@ class AdventureService:
except Exception:
logger.exception("Failed to mark entry/adventure as error")
async def _load_prior_entries_with_metadata(
async def _load_possible_choices_for_entries(
self,
all_entries: list[AdventureEntry],
) -> list[tuple[AdventureEntry, list, str | None]]:
user_id: uuid.UUID,
) -> list[tuple[AdventureEntry, list[AdventureEntryPossibleChoice], 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
Returns a list of (entry, choices, selected_choice_id) 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):
for entry in sorted(all_entries, key=lambda e: e.entry_index):
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))
decision = await self.decision_repo.get_for_entry_and_user(entry_id = uuid.UUID(entry.id), user_id=user_id)
selected_choice_id = decision.choice_id if decision else None
result.append((entry, choices, selected_choice_id))
return result

View file

@ -7,7 +7,7 @@ loads the data; these functions do the translation.
"""
import re
from ...domain.models.adventure import AdventureEntry, AdventureEntryPossibleChoice
from ...domain.models.adventure import AdventureEntry, AdventureEntryPossibleChoice, AdventureEntryPossibleChoiceDecision
def build_entry_system_prompt(
@ -19,28 +19,31 @@ def build_entry_system_prompt(
) -> 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). \n"
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)."
f"You are a game master running a single-player choose-your-own-adventure story "
f"to help the player practise {language_name}, write like a native. \n\n"
f"The session is {max_entry_count} turns. Deliver a satisfying narrative arc: "
f"establish, complicate, escalate, resolve. Don't force convergence until turn {halfway}. "
f"By turn {max_entry_count} the story must conclude clearly. "
f"Track the character the player is building through their choices and reflect it back.\n\n"
f"Write with economy and confidence. Favour scene over summary. "
f"Use dialogue to reveal character rather than reporting what was said. "
f"Resist the urge to over-explain — trust the player.\n\n"
f"Format — your response must have exactly three parts, each separated by a line containing only \"-----\":\n"
f"Part 1: story passage, {min_length}{max_length} words, in second person, "
f"written entirely in {language_name} at {competency} CEFR level. Plaintext only, no markdown.\n"
f"Part 2: exactly 4 numbered options (\"1.\" through \"4.\"), one per line, in {language_name}.\n"
f"Part 3: GM notes — three lines only:\n"
f" Character: one sentence on what this player's choices reveal about them. When empty, write 'None'.\n"
f" Threads: unresolved plot points or planted details that should pay off later.\n"
f" Next beat: what the next turn needs to do narratively.\n"
f" Do not describe unchosen options or recap what just happened.\n\n"
f"No sexual content or graphic violence. Romance, threat, and adventure are fine. "
f"12-certificate."
)
"""
SECTION: Title generation prompts
"""
def build_title_system_prompt() -> str:
return (
@ -48,9 +51,33 @@ def build_title_system_prompt() -> str:
"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)"
"Line 2: the description (max 200 characters, no quotes or labels)\n\n"
"Avoid the following tropes: 'The secret of [noun]', 'The [noun] of [noun]'"
)
def build_title_user_message(
first_entry_text: str,
language_name: str,
genres: list[str],
gamemaster_notes: str,
) -> str:
return (
f"This is the opening passage of a {', '.join(genres)} adventure written in {language_name}:\n\n"
f"{first_entry_text}\n\n"
f"The gamemaster has provided the following (hidden from the player) notes. "
f"Consider using non-spoiler details:\n{gamemaster_notes}"
)
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
def build_initial_user_message(
genres: list[str],
@ -63,18 +90,8 @@ def build_initial_user_message(
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}"
f"- Protagonist: {', '.join(protagonist)}\n\n"
"This entry will be used to title the adventure, so include clear hints about the overall story and main character."
)
@ -95,38 +112,34 @@ def build_conversation_messages(
setting: list[str],
vibes: list[str],
protagonist: list[str],
prior_entries: list[tuple[AdventureEntry, list[AdventureEntryPossibleChoice], str | None]],
prior_decisions: list[AdventureEntryPossibleChoice | None],
prior_entries_with_choices: 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).
prior_entries is a list of (entry, choices_for_that_entry, selected_choice_id).
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:
for entry, choices, selected_choice_id in prior_entries_with_choices:
chosen_choice = next((c for c in choices if c.id == selected_choice_id), None)
if selected_choice_id is None or chosen_choice is None:
# We have a problem, no decision was recorded for this entry
print(f"Warning: no decision found for entry {entry.id}")
continue
messages.append(
{"role": "assistant", "content": reconstruct_assistant_message(entry, choices)}
)
# Find the player's decision for this entry
choice_ids = [c.id for c in choices]
decision_for_entry = next(
(d for d in prior_decisions if d and d.choice_id in choice_ids),
None
)
messages.append({"role": "user", "content": chosen_choice.label})
# If a decision exists, append the player's chosen option
if decision_for_entry:
chosen_option = next(
(c for c in choices if c.id == decision_for_entry.choice_id),
None
)
if chosen_option:
messages.append({"role": "user", "content": chosen_option.label})
return messages
@ -162,12 +175,4 @@ def parse_entry_response(text: str) -> tuple[str, list[tuple[str, str]], str]:
return story_text, choices, gm_notes
def parse_title_response(text: str) -> tuple[str, str]:
"""Parse a two-line title/description response.
Returns (title, description). Falls back gracefully if only one line is present.
"""
lines = [l.strip() for l in text.strip().splitlines() if l.strip()]
title = lines[0][:60] if lines else "Untitled Adventure"
description = lines[1][:200] if len(lines) > 1 else ""
return title, description

View file

@ -61,6 +61,8 @@ class AdventureEntryEntity(Base):
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)
story_text_linguistic_data: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
pipeline_timing: 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)
)

View file

@ -54,6 +54,8 @@ def _to_entry(e: AdventureEntryEntity) -> AdventureEntry:
story_text=e.story_text,
gamemaster_notes=e.gamemaster_notes,
llm_data=e.llm_data,
story_text_linguistic_data=e.story_text_linguistic_data,
pipeline_timing=e.pipeline_timing,
created_at=e.created_at,
)
@ -246,6 +248,22 @@ class PostgresAdventureEntryRepository:
await self.db.refresh(entity)
return _to_entry(entity)
async def update_linguistic_data(
self,
entry_id: uuid.UUID,
story_text_linguistic_data: dict,
pipeline_timing: dict,
) -> AdventureEntry:
result = await self.db.execute(
select(AdventureEntryEntity).where(AdventureEntryEntity.id == entry_id)
)
entity = result.scalar_one()
entity.story_text_linguistic_data = story_text_linguistic_data
entity.pipeline_timing = pipeline_timing
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(
@ -329,6 +347,27 @@ class PostgresAdventureEntryDecisionRepository:
entity = result.scalar_one_or_none()
return _to_decision(entity) if entity else None
async def list_for_adventure_and_user(
self, adventure_id: uuid.UUID, user_id: uuid.UUID
) -> list[AdventureEntryPossibleChoiceDecision]:
result = await self.db.execute(
select(AdventureEntryPossibleChoiceDecisionEntity)
.join(
AdventureEntryPossibleChoiceEntity,
AdventureEntryPossibleChoiceDecisionEntity.choice_id
== AdventureEntryPossibleChoiceEntity.id,
)
.join(
AdventureEntryEntity,
AdventureEntryPossibleChoiceEntity.entry_id == AdventureEntryEntity.id,
)
.where(
AdventureEntryEntity.adventure_id == adventure_id,
AdventureEntryPossibleChoiceDecisionEntity.user_id == user_id,
)
)
return [_to_decision(e) for e in result.scalars().all()]
class PostgresAdventureEntryTranslationRepository:
def __init__(self, db: AsyncSession) -> None:

View file

@ -35,7 +35,10 @@ class SpacyClient:
# Recognise line-breaks as always being sentence boundaries, even if the model doesn't.
# This is important for the frontend to be able to show line-breaks in the source text.
nlp.add_pipe("sentencizer", before="parser")
# Guard prevents double-registration on the cached nlp object.
if "sentencizer" not in nlp.pipe_names:
before = "parser" if "parser" in nlp.pipe_names else None
nlp.add_pipe("sentencizer", before=before)
doc = nlp(text)

View file

@ -16,6 +16,7 @@ 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.spacy.spacy_client import SpacyClient
from ...outbound.postgres.repositories.adventure_repository import (
PostgresAdventureEntryAudioRepository,
PostgresAdventureEntryChoiceRepository,
@ -91,6 +92,11 @@ class _StubGeminiClient:
return buf.getvalue()
class _StubSpacyClient:
def get_parts_of_speech(self, text: str, language: str) -> dict:
return {"language": language, "sentences": [{"text": text, "tokens": []}]}
# ---------------------------------------------------------------------------
# Service factory
# ---------------------------------------------------------------------------
@ -101,10 +107,12 @@ def _make_service(db: AsyncSession) -> AdventureService:
anthropic = _StubAnthropicClient() # type: ignore[assignment]
deepl = _StubDeepLClient() # type: ignore[assignment]
gemini = _StubGeminiClient() # type: ignore[assignment]
spacy = _StubSpacyClient() # type: ignore[assignment]
else:
anthropic = AnthropicClient.new(settings.anthropic_api_key)
deepl = DeepLClient(settings.deepl_api_key)
gemini = GeminiClient(settings.gemini_api_key)
spacy = SpacyClient()
return AdventureService(
adventure_repo=PostgresAdventureRepository(db),
@ -116,6 +124,7 @@ def _make_service(db: AsyncSession) -> AdventureService:
anthropic_client=anthropic,
deepl_client=deepl,
gemini_client=gemini,
spacy_client=spacy,
)
@ -199,6 +208,7 @@ class EntryDetailResponse(BaseModel):
choices: list[ChoiceResponse]
translation: str | None
audio_file_name: str | None
story_text_linguistic_data: dict | None
# ---------------------------------------------------------------------------
@ -461,4 +471,5 @@ async def get_entry(
],
translation=translation.translated_text if translation else None,
audio_file_name=audio.file_name if audio else None,
story_text_linguistic_data=entry.story_text_linguistic_data,
)

View file

@ -33,6 +33,7 @@ class AdventureEntryItem(BaseModel):
status: str
entry_index: int
story_text: str | None
story_text_linguistic_data: dict | None
translation: str | None
audio_url: str | None
created_at: str
@ -108,6 +109,7 @@ async def get_adventure(
status=entry.status,
entry_index=entry.entry_index,
story_text=entry.story_text,
story_text_linguistic_data=entry.story_text_linguistic_data,
translation=translation.translated_text if translation else None,
audio_url=_audio_url(audio.file_name if audio else None),
created_at=entry.created_at.isoformat(),

View file

@ -1,174 +1,100 @@
# 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.
This is a semi-technical design document to detail some enhancements to 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.
Introduce structured linguistic data around entries in a "choose your own adventure", the purpose of which is to create a structured pathway from the user reading/listening to an entry, and then putting words in their vocab bank / word bank, and possibly then creating flashcards around them, to help them learn the words.
## Feature Description
In the website there is a tab, or page, called "Adventures".
The app already has the idea of an Adventure (i.e. a single story), for which there are many Entries, each of which have Possible Choices (4, for now), which the user selects and then the story continues to be generated.
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.
Entries are generated by a LLM (Claude), have a translation text generated by the DeepL translator, and are converted to audio by another LLM (Google's Gemini).
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).
You are to change the functionality to:
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.
1. Use the SpaCy natural language processing to break downt he generated (i.e. foreign language) text for an entry into their parts of speech and their sentences.
2. We are to translate these sentenses one at a time, and then the results from that translation are passed into the same SpaCy pipeline.
3. We need to end up with a data structure of `paragraphs` each of which has 1..n `sentences`, and the tokens (words) in that sentence have gone through the part-of-speech tagging system, as well as lemmatisation (these are already configured with how SpaCy is used elsewhere).
4. This structured data should be stored alongside the full-text as they are currently generated in the API, i.e. we need both the structured linguistic data as well as the original body text.
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.
## Technical components
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.
The `AdventureService` (`/app/domain/service/adventure_service.py`) contains a method called `run_entry_pipeline` - this is the highly asynchronous orchestrator of calls to various external parties (e.g. LLMs, translators, TTS), we should use this existing entrypoint to run the code.
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.)
We will need to inject a `SpacyClient` (`app/outbound/spacy/spacy_client`) into the `AdventureService`
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.
After the generation of the text (through the call to `anthropic_client.complete` in that method) we should (at a relevant point)
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.
Running the NLP pipeline in SpaCy won't get us the paragraphs, so we may need to split the incoming raw text by the `\n\n` separator, and then call the pipeline on each paragraph in turn.
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.
We will therefore need a JSON new field on the `AdventureEntryEntity`, which I think we should call `story_text_linguistic_data`, which should look like the following:
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.
```json
{
"source_language": "en",
"target_language": "fr",
"paragraphs": [
"index": 0,
"source_text": "\""Since forever, no? It's normal. Everyone is together here.\"",
"target_text": "« Depuis toujours, non ? C'est normal. Tout le monde est ensemble ici. »",
"sentences": [
{
"index": 0,
"source_text": "\"Since forever, no?",
"target_text": "« Depuis toujours, non ?",
"target_tokens": [..],
"source_tokens": [..],
## Monetisation and payment strategy
},
{
"index": 1,
"source_text": "It's normal",
"target_text": "C'est normal.",
"target_tokens": [..],
"source_tokens": [..],
},
{
"index": 2,
"source_text": "Everyone is together here.\"",
"target_text": "Tout le mond est ensemble ici. »",
"target_tokens": [..],
"source_tokens": [..],
},
]
]
}
```
Where the `tokens` fields are the same data structure as the tags specified in the `get_parts_of_speech` method in the SpacyClient.
We will then need to feed this data through to the front-end, which will use it to create a more structured set of data in the UI, which will aid in creating a better "translate" experience (i.e. click on a single word in the target language, and go to the relevant word(s) in the source language; be able to add words from that translation via a more automated pathway, with the option for manual intervention; linking of words with their dictionary entries, which we have)
This may have an impact on performance, can we therefore introduce a simple tracing mechanism into the `run_entry_pipeline` method, to give visibility about how long it take (in seconds) to run each individual step. Can we store this as JSON in the `AdventureEntryEntity`, so we'll need to createa migration to create those fields, I imagine some data that looks like:
```json
{
"durations": {
"text_generation": 10, // for the text itself
"translations_total": 5, // for all calls to DeepL combined,
"nlp_total": 7, // for all run s of SpaCy
"tts": 15, // call to generate the audio file
"file_uploading": 1 // To upload the .wav
}
}
```
## IGNORE: Monetisation and payment strategy
The following text is present as a reminder to me, to consider how adventure generation fits into the monetisation
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.
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 who wish to operate on the subscription model will get a certain number of Adventure entries per subscription period. We should round this up to the nearest adventure (you don't want to be waiting for your next renewal to finsih an adventure).
Users on a metered billing will pay for a whole adventure up-front, i.e. aprox. $1.20/adventure.
For this reason, it's very important that the system tracks the costs (in money, and in tokens) taken to generate the content for an adventure, so these figures can be adjusted to reflect reality.
## Example prompts
```txt
You are an experienced tabletop game master running a single-player one-shot campaign in a "choose your own adventure" format.
You are helping the player learn French. Your writing respects their intelligence, avoids too many cliches, delivers satisfying plot beats, and reads naturally.
The session is 8 turns. Each turn: you write a story passage, then offer 4 numbered choices. The player replies with their choice; you continue accordingly. By turn 8 there needs to be a clear end. As the player's choices reveal their character, weave those details back into the story. Don't railroad them until at least turn 4
Rules:
- Write entirely in French at B1 level. No markdown — plaintext only.
- Your response should be in three parts, each separated by a newline, and then five hyphens ("-----").
- The first section contains the story entry, 600700 words length total, speaking to the player directly.
- The second section contains contains a list of new-line separated player options, labelled 1,2,3,4 with explaining text.
- The third section are GM notes, hidden from the player, you may optionally use this section to record notes to your future self, to keep track of threads or ideas. If no notes, simply say "no notes"
- Your first message must establish: who the player is, the setting, and the broad direction of the story.
- No sexual content or graphic violence. Romance, threat, and adventure are fine. Treat this as a 12-certificate.
The scenario follows.
```
## Entities
### choose_your_own_adventure
This is the "header" entry, it represnts a single "adventure" in the format, right now it's linked to one user (via `user_id`) and holds details about the language and proficiency it's in, as a record of what was selected at the time.
The title is `Untitled adventure` and the description is empty when it gets created, but a separate call to an LLM will create a name and a description to put here.
```json
{
"id": "unique-uuid",
"user_id": "user-uuid",
"language": "fr",
"competencies": ["B1"],
"max_entry_length": 8,
"entry_story_text_target_length": { "min": 700, "max": 800},
"title": "Untitled adventure",
"description": null,
"plot_summary": null,
"genres": ["crime fiction"],
"setting": ["France", "city"],
"vibes": ["dark", "light humour"],
"protagonist": ["male", "reluctant", "late-teens"],
"created_at": "2026-05-03T09:00Z",
"deleted_at": null,
}
```
### choose_your_own_adventure_entry
An entry is like a "turn" in a tabletop roleplaying game, or a chapter in a choose your own adventure book. These are generated one at a time, in response to user choices (the first one is generated immediately after creation of the Adventure itself).
They are generated by an LLM using a prompt.
They are immediately translated (via DeepL) and have text-to-speech (via Google Gemini) from the story_text content.
Recording the `entry_index` and the `generated_from_possible_choice_id` allows us to model multiple replays of a specific adventure (e.g. "go back to step 3, and choose a different option to what I initially chose).
```json
{
"id": "uuid",
"choose_your_own_adventure_id": "unique-uuid",
"generated_from_possible_choice_id": "choose_your_own_adventure_entry_possible_choice-uuid", // null on entry 0
"llm_data": { "provider": "anthropic", "model": "claude-4.6" }, // JSONB for arbitrary data
"entry_index": "1", //
"story_text": "You find yourself in a big, dark woods...",
"gamemaster_notes": "The player is playing cautiously...", // Hidden from the user
"created_at": "2026-05-03T09:05",
}
```
### choose_your_own_adventure_entry_translation
This represents a translation of the generated story_text into the user's native language, to help them do parallel reading between the two texts.
```json
{
"id": "uuid",
"entry_id": "choose-your-own-adventure-entry-uuid",
"component_type": "story_text",
"target_language": "en",
"translated_text": "This is the translated text from the entry.story_text"
}
```
### choose_your_own_adventure_entry_audio
This is a text-to-speech (AI) generation of the story text, to make the content available to the user as e.g. a podcast feed, and also available on the screen.
```json
{
"id": "uuid",
"entry_id": "choose-your-own-adventure-entry-uuid",
"component_type": "story_text",
"tts_provider": "google_gemini",
"tts_options": { "voice": "voice name"}, // JSONB format
"file_name": "uuid-like-filename.mp4"
}
```
### choose_your_own_adventure_entry_possible_choice
This represents the options available to the user a the end of a specific entry, the LLM will generate 4 of them (initially).
```json
{
"id": "uuid",
"entry_id": "choose-your-own-adventure-entry-uuid",
"index": 0,
"label": "1",
"text": "Go into the dark house"
}
```
### choose_your_own_adventure_entry_possible_choice_decision
This represents the possible_choice that a user chose, which will be used to generate the next step of the story.
```json
{
"id": "uuid",
"choice_id": "choose_your_own_adventure_entry_possible_choice-uuid",
"user_id": "user-uuid",
"created_at": "2026-05-03T10:00:00.000Z"
}
```

File diff suppressed because one or more lines are too long

View file

@ -316,6 +316,12 @@ export type AdventureEntryItem = {
* Story Text
*/
story_text: string | null;
/**
* Story Text Linguistic Data
*/
story_text_linguistic_data: {
[key: string]: unknown;
} | null;
/**
* Translation
*/
@ -674,6 +680,12 @@ export type EntryDetailResponse = {
* Audio File Name
*/
audio_file_name: string | null;
/**
* Story Text Linguistic Data
*/
story_text_linguistic_data: {
[key: string]: unknown;
} | null;
};
/**

View file

@ -18,13 +18,13 @@ export const load: PageServerLoad = async ({ locals, params }) => {
return error(400, `Error loading adventure`);
}
const { title, entries, current_entry_choices, language } = response.data;
const { title, entries, current_entry_choices, language, status } = response.data;
response.data.entries.forEach((e) => console.log(e.story_text));
return {
title: title,
entries,
choices: current_entry_choices,
language: language
language: language,
status: status
};
};

View file

@ -1,18 +1,120 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import type { PageProps } from './$types';
import LatestEntry from './LatestEntry.svelte';
import PreviousEntries from './PreviousEntries.svelte';
import { locale, type Locale } from '$lib/i8n';
import { selectNextStep } from './selectNextStep.remote';
import { getAdventureRemote } from './getAdventure.remote';
import {
adventureState,
type AdventureEntry,
type AdventureStatus,
type GenerationPhase,
type NextStepOption
} from './adventureState';
const { data, params }: PageProps = $props();
const latestEntry = $derived(data.entries[data.entries.length - 1]);
let adventureTitle = $state(data.title as string);
const POLL_INTERVAL_MS = 25_000;
function toNextStepOptions(choices: { id: string; text: string }[]): NextStepOption[] {
return choices.map((choice) => ({
id: choice.id,
label: choice.text
}));
}
function getStatusMessage(phase: GenerationPhase): string {
switch (phase) {
case 'waiting-for-text':
return 'Writing your next scene...';
case 'waiting-for-translation':
return 'Preparing the translation...';
case 'waiting-for-audio':
return 'Generating narration audio...';
case 'error':
return 'Generation failed. Please try another choice.';
case 'ready':
return 'Your next entry is ready.';
default:
return '';
}
}
function getGenerationPhase(entry: AdventureEntry | undefined): GenerationPhase {
if (!entry) {
return 'idle';
}
if (entry.status === 'error') {
return 'error';
}
if (entry.story_text && entry.audio_url) {
return 'ready';
}
if (!entry.story_text) {
return 'waiting-for-text';
}
if (!entry.translation) {
return 'waiting-for-translation';
}
if (!entry.audio_url) {
return 'waiting-for-audio';
}
return 'ready';
}
function isGenerationTerminal(entry: AdventureEntry | undefined): boolean {
if (!entry) {
return false;
}
return entry.status === 'error' || (Boolean(entry.story_text) && Boolean(entry.audio_url));
}
const initialEntries = data.entries as AdventureEntry[];
const initialLatestEntry = initialEntries[initialEntries.length - 1];
const initialPhase = getGenerationPhase(initialLatestEntry);
adventureState.set({
entries: initialEntries,
nextStepsOptions: toNextStepOptions(data.choices),
status: data.status as AdventureStatus,
ui: {
isSelectingNextStep: false,
isPolling: false,
isWaitingForGeneration: !isGenerationTerminal(initialLatestEntry),
generationPhase: initialPhase,
statusMessage: getStatusMessage(initialPhase),
errorMessage: initialPhase === 'error' ? getStatusMessage('error') : null,
expectedMinEntryCount: null
}
});
let pollingTimer: ReturnType<typeof setInterval> | null = null;
let refreshInFlight = false;
const latestEntry = $derived.by(() => {
const entries = $adventureState.entries;
return entries[entries.length - 1];
});
const previousEntries = $derived.by(() => {
const allEntries = data.entries ?? [];
const entries =
$adventureState.status === 'complete'
? $adventureState.entries
: $adventureState.entries.slice(0, -1);
return allEntries.slice(0, -1).map((entry, index) => {
const nextEntry = allEntries[index + 1];
return entries.map((entry, index) => {
const nextEntry = entries[index + 1];
return {
id: entry.id,
@ -26,6 +128,143 @@
});
});
function startPolling() {
if (pollingTimer !== null) {
return;
}
adventureState.update((current) => ({
...current,
ui: {
...current.ui,
isPolling: true
}
}));
pollingTimer = setInterval(() => {
void refreshAdventure();
}, POLL_INTERVAL_MS);
}
function stopPolling() {
if (pollingTimer !== null) {
clearInterval(pollingTimer);
pollingTimer = null;
}
adventureState.update((current) => ({
...current,
ui: {
...current.ui,
isPolling: false
}
}));
}
async function refreshAdventure() {
if (refreshInFlight) {
return;
}
refreshInFlight = true;
try {
const adventure = await getAdventureRemote({ adventureId: params.id }).run();
if (adventureTitle == 'Untitled adventure') {
adventureTitle = adventure.title;
}
adventureState.update((current) => {
const hasExpectedEntry =
current.ui.expectedMinEntryCount === null ||
adventure.entries.length >= current.ui.expectedMinEntryCount;
const latest = adventure.entries[adventure.entries.length - 1] as
| AdventureEntry
| undefined;
const nextPhase = hasExpectedEntry ? getGenerationPhase(latest) : 'waiting-for-text';
const terminal = hasExpectedEntry && isGenerationTerminal(latest);
return {
entries: adventure.entries as AdventureEntry[],
nextStepsOptions: toNextStepOptions(adventure.current_entry_choices),
status: adventure.status as AdventureStatus,
ui: {
...current.ui,
isWaitingForGeneration: !terminal,
generationPhase: nextPhase,
statusMessage: getStatusMessage(nextPhase),
errorMessage: nextPhase === 'error' ? getStatusMessage('error') : null,
expectedMinEntryCount: hasExpectedEntry ? null : current.ui.expectedMinEntryCount
}
};
});
if (!$adventureState.ui.isWaitingForGeneration) {
stopPolling();
}
} catch (error) {
console.error('Failed to refresh adventure state', error);
adventureState.update((current) => ({
...current,
ui: {
...current.ui,
errorMessage: 'Unable to refresh generation state. Retrying...'
}
}));
} finally {
refreshInFlight = false;
}
}
async function handleNextStepSelect(optionId: string) {
if ($adventureState.ui.isSelectingNextStep) {
return;
}
const expectedMinEntryCount = $adventureState.entries.length + 1;
adventureState.update((current) => ({
...current,
ui: {
...current.ui,
isSelectingNextStep: true,
isWaitingForGeneration: true,
generationPhase: 'waiting-for-text',
statusMessage: getStatusMessage('waiting-for-text'),
errorMessage: null,
expectedMinEntryCount
}
}));
try {
await selectNextStep({ adventureId: params.id, possibleChoiceId: optionId });
startPolling();
await refreshAdventure();
} catch (error) {
console.error('Failed to select next step', error);
stopPolling();
adventureState.update((current) => ({
...current,
ui: {
...current.ui,
isWaitingForGeneration: false,
generationPhase: 'error',
statusMessage: getStatusMessage('error'),
errorMessage: 'Could not start the next entry. Please try again.',
expectedMinEntryCount: null
}
}));
} finally {
adventureState.update((current) => ({
...current,
ui: {
...current.ui,
isSelectingNextStep: false
}
}));
}
}
onMount(() => {
$locale = data.language as Locale;
@ -34,27 +273,40 @@
if (latestStoryElement) {
latestStoryElement.scrollIntoView({ behavior: 'smooth' });
}
if ($adventureState.ui.isWaitingForGeneration) {
startPolling();
void refreshAdventure();
}
});
onDestroy(() => {
stopPolling();
});
</script>
<div class="adventure-page">
<header class="adventure-page__header">
<p class="adventure-page__kicker">Choose your own adventure</p>
<h1 class="adventure-page__title">{data.title}</h1>
<h1 class="adventure-page__title">{adventureTitle}</h1>
</header>
<PreviousEntries entries={previousEntries} />
{#if $adventureState.status === 'awaiting_first_entry'}
<p class="adventure-page__awaiting-entry">Waiting for the first entry...</p>
{:else}
<PreviousEntries entries={previousEntries} />
<LatestEntry
sourceText={latestEntry.story_text}
translationText={latestEntry.translation}
audioUrl={latestEntry.audio_url!}
nextStepsOptions={data.choices.map((choice) => ({
label: choice.text,
id: choice.id
}))}
adventureId={params.id}
/>
<LatestEntry
sourceText={latestEntry?.story_text}
translationText={latestEntry?.translation}
audioUrl={latestEntry?.audio_url}
onSelectNextStep={handleNextStepSelect}
isWaitingForGeneration={$adventureState.ui.isWaitingForGeneration}
generationPhase={$adventureState.ui.generationPhase}
statusMessage={$adventureState.ui.statusMessage}
errorMessage={$adventureState.ui.errorMessage}
/>
{/if}
</div>
<style>

View file

@ -1,16 +1,37 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import NextSteps from './NextSteps.svelte';
import { selectNextStep } from './selectNextStep.remote';
import { adventureState } from './adventureState';
type Props = {
adventureId: string;
sourceText: string | null | undefined;
translationText: string | null | undefined;
audioUrl: string;
nextStepsOptions: { label: string; id: string }[];
audioUrl: string | null | undefined;
onSelectNextStep: (optionId: string) => Promise<void>;
isWaitingForGeneration: boolean;
generationPhase:
| 'idle'
| 'waiting-for-text'
| 'waiting-for-translation'
| 'waiting-for-audio'
| 'ready'
| 'error';
statusMessage: string;
errorMessage: string | null;
};
const { adventureId, sourceText, translationText, audioUrl, nextStepsOptions }: Props = $props();
const {
sourceText,
translationText,
audioUrl,
onSelectNextStep,
isWaitingForGeneration,
generationPhase,
statusMessage,
errorMessage
}: Props = $props();
const sourceParagraphs = $derived.by(() => toParagraphs(sourceText));
const translationParagraphs = $derived.by(() => toParagraphs(translationText));
@ -66,6 +87,10 @@
}
function showTranslation() {
if (!translationText) {
return;
}
translationVisible = true;
if (translationTimer !== null) {
clearTimeout(translationTimer);
@ -81,75 +106,125 @@
}
async function handleNextStepSelect(optionId: string) {
const result = await selectNextStep({ adventureId, possibleChoiceId: optionId });
console.log({ result });
await onSelectNextStep(optionId);
}
onDestroy(() => {
if (translationTimer !== null) {
clearTimeout(translationTimer);
}
});
</script>
<section class="latest-story" aria-label="Current story entry" id="latest-story">
<header class="latest-story__header">
<div class="latest-story__title-group">
<p class="latest-story__kicker">Current entry</p>
<h2 class="latest-story__title">Now reading</h2>
</div>
{#if $adventureState.status === 'active'}
<section class="latest-story" aria-label="Current story entry" id="latest-story">
<header class="latest-story__header">
<div class="latest-story__title-group">
<p class="latest-story__kicker">Current entry</p>
<h2 class="latest-story__title">Now reading</h2>
{#if isWaitingForGeneration}
<p class="generation-status" data-phase={generationPhase}>{statusMessage}</p>
{:else if errorMessage}
<p class="generation-status" data-phase="error">{errorMessage}</p>
{/if}
</div>
<div class="audio-dock" aria-label="Listening controls">
<p class="audio-dock__label">Listen</p>
<audio class="audio-dock__player" controls preload="metadata">
<source src={audioUrl} type="audio/wav" />
</audio>
</div>
</header>
<div class="audio-dock" aria-label="Listening controls">
<p class="audio-dock__label">Listen</p>
{#if audioUrl}
<audio class="audio-dock__player" controls preload="metadata">
<source src={audioUrl} type="audio/wav" />
</audio>
{:else}
<div class="audio-dock__skeleton" aria-hidden="true"></div>
<p class="audio-dock__pending">{statusMessage || 'Narration is being generated...'}</p>
{/if}
</div>
</header>
<div class="latest-entry">
<div class="pane source-pane">
<div class="latest-entry__pane-body" bind:this={sourcePane} onscroll={handleSourceScroll}>
{#each sourceParagraphs as paragraph, index (index)}
<p
class="paragraph"
class:active={lastClickedParagraphIndex === index}
data-paragraph-index={index}
data-language="source"
onclick={() => handleParagraphClicked(index)}
<div class="latest-entry">
<div class="pane source-pane">
<div class="latest-entry__pane-body" bind:this={sourcePane} onscroll={handleSourceScroll}>
{#if sourceParagraphs.length > 0}
{#each sourceParagraphs as paragraph, index (index)}
<button
type="button"
class="paragraph"
class:active={lastClickedParagraphIndex === index}
data-paragraph-index={index}
data-language="source"
onclick={() => handleParagraphClicked(index)}
>
{paragraph}
</button>
{/each}
{:else}
<div class="loading-block" role="status" aria-live="polite">
<p class="loading-block__label">{statusMessage || 'Writing your next entry...'}</p>
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
{/if}
</div>
</div>
<div class="pane translation-pane" data-visible={translationVisible}>
<header class="translation-header">
<p class="translation-header__label">Translation</p>
<button class="dict-toggle" onclick={showTranslation} disabled={!translationText}>
<span class="dict-toggle-label">Reveal for 20 seconds</span>
</button>
</header>
{#if translationVisible}
<div
class="latest-entry__pane-body"
bind:this={translationPane}
onscroll={handleTranslationScroll}
>
{paragraph}
{#if translationParagraphs.length > 0}
{#each translationParagraphs as paragraph, index (index)}
<button
type="button"
class="paragraph"
class:active={lastClickedParagraphIndex === index}
data-paragraph-index={index}
data-language="translation"
onclick={() => handleParagraphClicked(index)}
>
{paragraph}
</button>
{/each}
{:else}
<div class="loading-block" role="status" aria-live="polite">
<p class="loading-block__label">
{generationPhase === 'waiting-for-text'
? 'Translation starts after the story text is ready.'
: 'Translation is on the way...'}
</p>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
{/if}
</div>
{:else if !translationText}
<p class="translation-hint" role="status" aria-live="polite">
{generationPhase === 'waiting-for-text'
? 'Translation will appear after the next scene is written.'
: 'Translation is still being prepared.'}
</p>
{/each}
{/if}
</div>
</div>
</section>
<div class="pane translation-pane" data-visible={translationVisible}>
<header class="translation-header">
<p class="translation-header__label">Translation</p>
<button class="dict-toggle" onclick={showTranslation}>
<span class="dict-toggle-label">Reveal for 20 seconds</span>
</button>
</header>
{#if translationVisible}
<div
class="latest-entry__pane-body"
bind:this={translationPane}
onscroll={handleTranslationScroll}
>
{#each translationParagraphs as paragraph, index (index)}
<p
class="paragraph"
class:active={lastClickedParagraphIndex === index}
data-paragraph-index={index}
data-language="translation"
onclick={() => handleParagraphClicked(index)}
>
{paragraph}
</p>
{/each}
</div>
{/if}
</div>
</div>
</section>
<NextSteps options={nextStepsOptions} onSelect={handleNextStepSelect} />
<NextSteps
onSelect={handleNextStepSelect}
disabled={isWaitingForGeneration}
busyLabel={statusMessage}
/>
{/if}
<style>
.latest-story {
@ -188,6 +263,36 @@
color: var(--color-on-surface);
}
.generation-status {
margin: var(--space-2) 0 0;
font-family: var(--font-label);
font-size: var(--text-label-md);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: color-mix(in srgb, var(--color-primary) 76%, var(--color-on-surface));
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.generation-status::before {
content: '';
width: 0.5rem;
height: 0.5rem;
border-radius: var(--radius-full);
background-color: var(--color-primary);
animation: pulse 1.3s ease-in-out infinite;
}
.generation-status[data-phase='error'] {
color: color-mix(in srgb, #d9534f 86%, var(--color-on-surface));
}
.generation-status[data-phase='error']::before {
background-color: #d9534f;
animation: none;
}
.audio-dock {
padding: var(--space-3);
border-radius: var(--radius-lg);
@ -212,6 +317,25 @@
accent-color: var(--color-primary);
}
.audio-dock__skeleton {
height: 2.5rem;
border-radius: var(--radius-md);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--color-surface-container) 82%, transparent),
color-mix(in srgb, var(--color-primary-container) 35%, transparent),
color-mix(in srgb, var(--color-surface-container) 82%, transparent)
);
background-size: 220% 100%;
animation: shimmer 1.8s linear infinite;
}
.audio-dock__pending {
margin: 0;
font-size: var(--text-label-md);
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
}
.latest-entry {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@ -295,6 +419,12 @@
transform: translateY(0);
}
.dict-toggle:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.dict-toggle-label {
white-space: nowrap;
}
@ -320,6 +450,67 @@
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.4;
transform: scale(0.94);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@keyframes shimmer {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
.loading-block {
display: grid;
gap: var(--space-2);
}
.loading-block__label {
margin: 0;
font-family: var(--font-label);
font-size: var(--text-label-md);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
}
.skeleton-line {
height: 1.05rem;
border-radius: var(--radius-full);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--color-surface-container) 86%, transparent),
color-mix(in srgb, var(--color-primary-container) 35%, transparent),
color-mix(in srgb, var(--color-surface-container) 86%, transparent)
);
background-size: 220% 100%;
animation: shimmer 1.8s linear infinite;
}
.skeleton-line.short {
width: 66%;
}
.translation-hint {
margin: 0;
padding: 0 var(--latest-entry-pane-padding) var(--latest-entry-pane-padding);
font-size: var(--text-label-md);
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
}
.latest-entry__pane-body::-webkit-scrollbar {
width: 0.75rem;
}
@ -336,6 +527,12 @@
}
.paragraph {
display: block;
width: 100%;
padding: 0;
border: none;
background: transparent;
text-align: left;
font-size: clamp(1.1rem, 1rem + 0.35vw, 1.35rem);
line-height: var(--leading-relaxed);
color: var(--color-on-surface);
@ -350,6 +547,11 @@
border-radius: var(--radius-md);
}
.paragraph:focus-visible {
outline: 2px solid color-mix(in srgb, var(--color-primary) 45%, transparent);
outline-offset: 2px;
}
.paragraph + .paragraph {
margin-top: var(--space-3);
padding-top: var(--space-3);

View file

@ -1,10 +1,17 @@
<script lang="ts">
import { adventureState } from './adventureState';
type Props = {
options: { label: string; id: string }[];
onSelect: (optionId: string) => void;
disabled?: boolean;
busyLabel?: string;
};
const { options, onSelect }: Props = $props();
const {
onSelect,
disabled = false,
busyLabel = 'Generating your next entry...'
}: Props = $props();
let isSubmitting = $state(false);
@ -22,30 +29,35 @@
};
</script>
<section class="next-steps" aria-label="Choose what happens next">
<header class="next-steps__header">
<p class="next-steps__kicker">Choose your path</p>
<h2 class="next-steps__title">What happens next?</h2>
</header>
{#if $adventureState.status !== 'complete'}
<section class="next-steps" aria-label="Choose what happens next">
<header class="next-steps__header">
<p class="next-steps__kicker">Choose your path</p>
<h2 class="next-steps__title">What happens next?</h2>
{#if disabled}
<p class="next-steps__status" role="status" aria-live="polite">{busyLabel}</p>
{/if}
</header>
<ol class="next-steps__list">
{#each options as option, index (option.id)}
<li class="next-steps__item">
<button
class="next-steps__button"
onclick={() => handleOptionSelect(option.id)}
disabled={isSubmitting}
>
<span class="next-steps__index" aria-hidden="true"
>{String(index + 1).padStart(2, '0')}</span
<ol class="next-steps__list">
{#each $adventureState.nextStepsOptions as option, index (option.id)}
<li class="next-steps__item">
<button
class="next-steps__button"
onclick={() => handleOptionSelect(option.id)}
disabled={isSubmitting || disabled}
>
<span class="next-steps__label">{option.label}</span>
<span class="next-steps__meta" aria-hidden="true">Choose</span>
</button>
</li>
{/each}
</ol>
</section>
<span class="next-steps__index" aria-hidden="true"
>{String(index + 1).padStart(2, '0')}</span
>
<span class="next-steps__label">{option.label}</span>
<span class="next-steps__meta" aria-hidden="true">Choose</span>
</button>
</li>
{/each}
</ol>
</section>
{/if}
<style>
.next-steps {
@ -80,6 +92,15 @@
color: var(--color-on-surface);
}
.next-steps__status {
margin: var(--space-2) 0 0;
font-family: var(--font-label);
font-size: var(--text-label-md);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent);
}
.next-steps__list {
list-style: none;
padding: 0;

View file

@ -167,6 +167,7 @@
}
.entry-card__choices {
width: 100%;
max-width: 65ch;
font-size: var(--text-body-md);
}

View file

@ -0,0 +1,56 @@
import { writable } from 'svelte/store';
export type AdventureEntry = {
id: string;
story_text: string | null;
translation: string | null;
audio_url: string | null;
generated_from_choice_id: string | null;
possible_choices: { id: string; text: string }[] | null;
status: string;
};
export type NextStepOption = {
id: string;
label: string;
};
export type GenerationPhase =
| 'idle'
| 'waiting-for-text'
| 'waiting-for-translation'
| 'waiting-for-audio'
| 'ready'
| 'error';
export type AdventureStatus = 'active' | 'complete' | 'awaiting_first_entry';
export type AdventurePageState = {
entries: AdventureEntry[];
nextStepsOptions: NextStepOption[];
status: AdventureStatus;
ui: {
isSelectingNextStep: boolean;
isPolling: boolean;
isWaitingForGeneration: boolean;
generationPhase: GenerationPhase;
statusMessage: string;
errorMessage: string | null;
expectedMinEntryCount: number | null;
};
};
export const adventureState = writable<AdventurePageState>({
entries: [],
nextStepsOptions: [],
status: 'active',
ui: {
isSelectingNextStep: false,
isPolling: false,
isWaitingForGeneration: false,
generationPhase: 'idle',
statusMessage: '',
errorMessage: null,
expectedMinEntryCount: null
}
});

View file

@ -0,0 +1,26 @@
import { getRequestEvent, query } from '$app/server';
import { getAdventureBffAdventureAdventureIdGet } from '@client';
import * as v from 'valibot';
const getAdventureStateSchema = v.object({
adventureId: v.string()
});
export const getAdventureRemote = query(getAdventureStateSchema, async ({ adventureId }) => {
const { locals } = getRequestEvent();
const response = await getAdventureBffAdventureAdventureIdGet({
path: {
adventure_id: adventureId
},
headers: {
Authorization: `Bearer ${locals.authToken}`
}
});
if (response.error) {
console.error('Error fetching adventure state:', response.error);
throw new Error('Failed to fetch adventure state');
}
return response.data;
});

View file

@ -6,63 +6,95 @@ import { randomItemInArray, shuffleArray } from '$lib';
import { formatLanguage } from '$lib/formatters';
const allVibes = [
'Melancholic',
'Gothic',
'Sun-drenched',
'Bleak',
'Whimsical',
'Eerie',
'Cosy',
'Tense',
'Witty',
'Propulsive',
'Mentor and student',
'Unlikely duo',
'Lone wolf',
'Queer-norm',
'Class tensions',
'Chosen family',
'Diaspora',
'Academia',
'Small town',
'The sea',
'Grand house',
'Road trip',
'A single night',
'Heist',
'Mystery box',
'Reluctant hero',
'Redemption',
'Academia',
'Animal companions',
'Gentle',
'Happy ever after',
'Bittersweet',
'Epistolary (letters / diary entries)',
"A big city that isn't the capital",
'Parenthood',
'Sly',
'Slapstick',
'Recovery',
'Political',
'Apocalyptic',
'Australia',
'Bittersweet',
'Bleak',
'Boarding school',
'Bookish',
'Central America',
'Central Asia',
'Chosen family',
'Class tensions',
'Cosy',
'Diaspora',
'East Asia',
'Eastern Africa',
'Eastern Europe',
'Eerie',
'Epistolary (letters / diary entries)',
'Gentle',
'Ghost',
'Gothic',
'Grand house',
'Happy ever after',
'Heist',
'Island',
'Lifelong friendship',
'Lone wolf',
'Mafia',
'Masculine',
'Matriarchal society',
'Mediterranean',
'Melancholic',
'Melodrama',
'Mentor and student',
'Mystery box',
'Nordic',
'North America',
'Northern Africa',
'Paranormal',
'Parenthood',
'Parenthood',
'Plot twist near the end',
'Political',
'Post-apocalyptic',
'Survival',
'War',
'Propulsive',
'Pulp',
'Queer-norm',
'Recovery',
'Redemption',
'Reluctant hero',
'Road trip',
'Slapstick',
'Sly',
'Small town',
'Southeast Asia',
'Southern Africa',
'Spy thriller',
'Time travel'
'Starving artist',
'Sun-drenched',
'Survival',
'Tense',
'The sea',
'Time travel',
'Tropical',
'Unlikely duo',
'Unreliable narrator',
'War',
'West Asia',
'Western Africa',
'Whimsical',
'Witty'
];
const allGenres = [
'Adventure',
'Crime Fiction',
'Crime noir',
'Who-dun-it mystery',
'Paranormal',
'Horror',
'Psychological thriller',
'Romance',
'Family',
'Fantasy',
'Science Fiction'
'Horror',
'Mystery',
'Paranormal',
'Psychological thriller',
'Romance',
'Science Fiction',
'Thriller',
'Who-dun-it mystery'
];
export const load: PageServerLoad = async ({ locals }) => {
let languageCode = 'fr';

View file

@ -4,7 +4,7 @@ import { getJobsApiJobsGet } from '../../../client/sdk.gen.ts';
export const load: PageServerLoad = async ({ locals }) => {
const authToken = locals.authToken;
console.log({ authToken });
client.setConfig({
headers: {
Authorization: `Bearer ${authToken}`