Compare commits
No commits in common. "941396fc6076e9bd6c8e0927b406b53aba7fbfed" and "01e09680c89e8c5a87c9a452c6108762a223776a" have entirely different histories.
941396fc60
...
01e09680c8
21 changed files with 400 additions and 1118 deletions
|
|
@ -1,31 +0,0 @@
|
||||||
"""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")
|
|
||||||
|
|
@ -33,8 +33,6 @@ class AdventureEntry:
|
||||||
story_text: str | None
|
story_text: str | None
|
||||||
gamemaster_notes: str | None
|
gamemaster_notes: str | None
|
||||||
llm_data: dict | None
|
llm_data: dict | None
|
||||||
story_text_linguistic_data: dict | None
|
|
||||||
pipeline_timing: dict | None
|
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from ...languages import SUPPORTED_LANGUAGES
|
from ...languages import SUPPORTED_LANGUAGES
|
||||||
|
|
@ -23,23 +21,16 @@ from ...outbound.postgres.repositories.adventure_repository import (
|
||||||
PostgresAdventureEntryTranslationRepository,
|
PostgresAdventureEntryTranslationRepository,
|
||||||
PostgresAdventureRepository,
|
PostgresAdventureRepository,
|
||||||
)
|
)
|
||||||
from ...outbound.spacy.spacy_client import SpacyClient
|
|
||||||
from ...storage import upload_audio
|
from ...storage import upload_audio
|
||||||
from ..models.adventure import (
|
from ..models.adventure import (
|
||||||
Adventure,
|
Adventure,
|
||||||
AdventureEntry,
|
AdventureEntry,
|
||||||
AdventureEntryPossibleChoice,
|
|
||||||
AdventureEntryPossibleChoiceDecision,
|
AdventureEntryPossibleChoiceDecision,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class AdventureService:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -52,7 +43,6 @@ class AdventureService:
|
||||||
anthropic_client: AnthropicClient,
|
anthropic_client: AnthropicClient,
|
||||||
deepl_client: DeepLClient,
|
deepl_client: DeepLClient,
|
||||||
gemini_client: GeminiClient,
|
gemini_client: GeminiClient,
|
||||||
spacy_client: SpacyClient,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.adventure_repo = adventure_repo
|
self.adventure_repo = adventure_repo
|
||||||
self.entry_repo = entry_repo
|
self.entry_repo = entry_repo
|
||||||
|
|
@ -63,7 +53,6 @@ class AdventureService:
|
||||||
self.anthropic_client = anthropic_client
|
self.anthropic_client = anthropic_client
|
||||||
self.deepl_client = deepl_client
|
self.deepl_client = deepl_client
|
||||||
self.gemini_client = gemini_client
|
self.gemini_client = gemini_client
|
||||||
self.spacy_client = spacy_client
|
|
||||||
|
|
||||||
async def create_adventure_for_user(
|
async def create_adventure_for_user(
|
||||||
self,
|
self,
|
||||||
|
|
@ -159,8 +148,8 @@ class AdventureService:
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Full entry generation pipeline. Called from the worker queue.
|
"""Full entry generation pipeline. Called from the worker queue.
|
||||||
|
|
||||||
Sequence: LLM generation → parse → persist → NLP + per-sentence translation →
|
Sequence: LLM generation → parse → persist → translate → TTS →
|
||||||
TTS → adventure title (first entry only) → update adventure status.
|
adventure title (first entry only) → update adventure status.
|
||||||
|
|
||||||
On any error the entry and adventure are marked 'error'.
|
On any error the entry and adventure are marked 'error'.
|
||||||
"""
|
"""
|
||||||
|
|
@ -169,21 +158,20 @@ class AdventureService:
|
||||||
assert adventure is not None, f"Adventure {adventure_id} not found"
|
assert adventure is not None, f"Adventure {adventure_id} not found"
|
||||||
|
|
||||||
all_entries = await self.entry_repo.list_for_adventure(adventure_id)
|
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))
|
current_entry = next(e for e in all_entries if e.id == str(entry_id))
|
||||||
is_first_entry = current_entry.entry_index == 0
|
is_first_entry = current_entry.entry_index == 0
|
||||||
is_final_entry = current_entry.entry_index + 1 == adventure.max_entry_count
|
is_final_entry = current_entry.entry_index + 1 == adventure.max_entry_count
|
||||||
|
|
||||||
prior_entries_with_possible_choices = await self._load_possible_choices_for_entries(
|
prior_entries = await self._load_prior_entries_with_metadata(
|
||||||
all_entries=[
|
all_entries=[
|
||||||
e for e in all_entries if e.entry_index < current_entry.entry_index
|
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"
|
competency = adventure.competencies[0] if adventure.competencies else "B1"
|
||||||
system_prompt = build_entry_system_prompt(
|
system_prompt = build_entry_system_prompt(
|
||||||
language_name=language_name,
|
language_name=language_name,
|
||||||
|
|
@ -197,17 +185,15 @@ class AdventureService:
|
||||||
setting=adventure.setting,
|
setting=adventure.setting,
|
||||||
vibes=adventure.vibes,
|
vibes=adventure.vibes,
|
||||||
protagonist=adventure.protagonist,
|
protagonist=adventure.protagonist,
|
||||||
prior_entries_with_choices=prior_entries_with_possible_choices,
|
prior_entries=prior_entries,
|
||||||
|
prior_decisions=all_decisions,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── LLM generation ──────────────────────────────────────────────
|
|
||||||
t0 = time.monotonic()
|
|
||||||
raw_text, usage_dict = await self.anthropic_client.complete(
|
raw_text, usage_dict = await self.anthropic_client.complete(
|
||||||
system_prompt=system_prompt,
|
system_prompt=system_prompt,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
max_tokens=2048,
|
max_tokens=2048,
|
||||||
)
|
)
|
||||||
timing_text_generation = time.monotonic() - t0
|
|
||||||
|
|
||||||
story_text, choices_parsed, gm_notes = parse_entry_response(raw_text)
|
story_text, choices_parsed, gm_notes = parse_entry_response(raw_text)
|
||||||
|
|
||||||
|
|
@ -228,96 +214,21 @@ class AdventureService:
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Per-sentence NLP + translation ───────────────────────────────
|
translated = await self.deepl_client.translate(
|
||||||
target_lang = _base_language(adventure.language)
|
story_text, adventure.source_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(
|
await self.translation_repo.create(
|
||||||
entry_id=entry_id,
|
entry_id=entry_id,
|
||||||
component_type="story_text",
|
component_type="story_text",
|
||||||
target_language=adventure.source_language,
|
target_language=adventure.source_language,
|
||||||
translated_text=full_translated_text,
|
translated_text=translated,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── TTS ──────────────────────────────────────────────────────────
|
|
||||||
t0 = time.monotonic()
|
|
||||||
voice = self.gemini_client.get_voice_by_language(adventure.language)
|
voice = self.gemini_client.get_voice_by_language(adventure.language)
|
||||||
story_text_with_tag = "[like a dungeons and dragons gamemaster] " + story_text
|
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)
|
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"
|
audio_key = f"adventure-audio/{entry_id}.wav"
|
||||||
upload_audio(audio_key, wav_bytes)
|
upload_audio(audio_key, wav_bytes)
|
||||||
timing_file_uploading = time.monotonic() - t0
|
|
||||||
|
|
||||||
await self.audio_repo.create(
|
await self.audio_repo.create(
|
||||||
entry_id=entry_id,
|
entry_id=entry_id,
|
||||||
component_type="story_text",
|
component_type="story_text",
|
||||||
|
|
@ -326,27 +237,10 @@ class AdventureService:
|
||||||
file_name=audio_key,
|
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:
|
if is_first_entry:
|
||||||
title_system = build_title_system_prompt()
|
title_system = build_title_system_prompt()
|
||||||
title_user = build_title_user_message(
|
title_user = build_title_user_message(
|
||||||
story_text, language_name, adventure.genres, gm_notes
|
story_text, language_name, adventure.genres
|
||||||
)
|
)
|
||||||
title_raw, _ = await self.anthropic_client.complete(
|
title_raw, _ = await self.anthropic_client.complete(
|
||||||
system_prompt=title_system,
|
system_prompt=title_system,
|
||||||
|
|
@ -373,21 +267,32 @@ class AdventureService:
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Failed to mark entry/adventure as error")
|
logger.exception("Failed to mark entry/adventure as error")
|
||||||
|
|
||||||
async def _load_possible_choices_for_entries(
|
async def _load_prior_entries_with_metadata(
|
||||||
self,
|
self,
|
||||||
all_entries: list[AdventureEntry],
|
all_entries: list[AdventureEntry],
|
||||||
user_id: uuid.UUID,
|
) -> list[tuple[AdventureEntry, list, str | None]]:
|
||||||
) -> list[tuple[AdventureEntry, list[AdventureEntryPossibleChoice], str | None]]:
|
|
||||||
"""Load choices for each prior entry and determine which choice was made.
|
"""Load choices for each prior entry and determine which choice was made.
|
||||||
|
|
||||||
Returns a list of (entry, choices, selected_choice_id) tuples ready for
|
Returns a list of (entry, choices, chosen_label_or_None) tuples ready for
|
||||||
build_conversation_messages().
|
build_conversation_messages().
|
||||||
"""
|
"""
|
||||||
|
sorted_entries = sorted(all_entries, key=lambda e: e.entry_index)
|
||||||
result = []
|
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))
|
choices = await self.choice_repo.list_for_entry(uuid.UUID(entry.id))
|
||||||
decision = await self.decision_repo.get_for_entry_and_user(entry_id = uuid.UUID(entry.id), user_id=user_id)
|
chosen_label: str | None = None
|
||||||
selected_choice_id = decision.choice_id if decision else None
|
if i + 1 < len(sorted_entries):
|
||||||
result.append((entry, choices, selected_choice_id))
|
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
|
return result
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ loads the data; these functions do the translation.
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from ...domain.models.adventure import AdventureEntry, AdventureEntryPossibleChoice, AdventureEntryPossibleChoiceDecision
|
from ...domain.models.adventure import AdventureEntry, AdventureEntryPossibleChoice
|
||||||
|
|
||||||
|
|
||||||
def build_entry_system_prompt(
|
def build_entry_system_prompt(
|
||||||
|
|
@ -19,31 +19,28 @@ def build_entry_system_prompt(
|
||||||
) -> str:
|
) -> str:
|
||||||
halfway = max(1, max_entry_count // 2)
|
halfway = max(1, max_entry_count // 2)
|
||||||
return (
|
return (
|
||||||
f"You are a game master running a single-player choose-your-own-adventure story "
|
f"You are an experienced tabletop game master running a single-player one-shot campaign "
|
||||||
f"to help the player practise {language_name}, write like a native. \n\n"
|
f"in a \"choose your own adventure\" format.\n\n"
|
||||||
f"The session is {max_entry_count} turns. Deliver a satisfying narrative arc: "
|
f"You are helping the player learn {language_name}. Your writing respects their "
|
||||||
f"establish, complicate, escalate, resolve. Don't force convergence until turn {halfway}. "
|
f"intelligence, avoids too many clichés, delivers satisfying plot beats, and reads naturally.\n\n"
|
||||||
f"By turn {max_entry_count} the story must conclude clearly. "
|
f"The session is {max_entry_count} turns. Each turn: you write a story passage, then offer "
|
||||||
f"Track the character the player is building through their choices and reflect it back.\n\n"
|
f"4 numbered choices. The player replies with their choice; you continue accordingly. "
|
||||||
f"Write with economy and confidence. Favour scene over summary. "
|
f"By turn {max_entry_count} there needs to be a clear end. As the player's choices reveal "
|
||||||
f"Use dialogue to reveal character rather than reporting what was said. "
|
f"their character, weave those details back into the story. "
|
||||||
f"Resist the urge to over-explain — trust the player.\n\n"
|
f"Don't railroad them until at least turn {halfway}.\n\n"
|
||||||
f"Format — your response must have exactly three parts, each separated by a line containing only \"-----\":\n"
|
f"Rules:\n"
|
||||||
f"Part 1: story passage, {min_length}–{max_length} words, in second person, "
|
f"- Write entirely in {language_name} at {competency} level on the CEFR scale. "
|
||||||
f"written entirely in {language_name} at {competency} CEFR level. Plaintext only, no markdown.\n"
|
f"No markdown — plaintext only.\n"
|
||||||
f"Part 2: exactly 4 numbered options (\"1.\" through \"4.\"), one per line, in {language_name}.\n"
|
f"- Your response MUST be in exactly three parts, each separated by a line containing "
|
||||||
f"Part 3: GM notes — three lines only:\n"
|
f"only \"-----\".\n"
|
||||||
f" Character: one sentence on what this player's choices reveal about them. When empty, write 'None'.\n"
|
f"- Part 1: the story entry, {min_length}–{max_length} words, speaking directly to the player.\n"
|
||||||
f" Threads: unresolved plot points or planted details that should pay off later.\n"
|
f"- Part 2: exactly 4 numbered player options, one per line, labelled \"1.\", \"2.\", \"3.\", \"4.\".\n"
|
||||||
f" Next beat: what the next turn needs to do narratively.\n"
|
f"- Part 3: GM notes to your future self (hidden from the player). \n"
|
||||||
f" Do not describe unchosen options or recap what just happened.\n\n"
|
f"If no notes, write \"no notes\".\n"
|
||||||
f"No sexual content or graphic violence. Romance, threat, and adventure are fine. "
|
f"- Your first message must establish: who the player is, the setting, and the broad direction.\n"
|
||||||
f"12-certificate."
|
f"- No sexual content or graphic violence. Romance, threat, and adventure are fine (12-certificate)."
|
||||||
)
|
)
|
||||||
|
|
||||||
"""
|
|
||||||
SECTION: Title generation prompts
|
|
||||||
"""
|
|
||||||
|
|
||||||
def build_title_system_prompt() -> str:
|
def build_title_system_prompt() -> str:
|
||||||
return (
|
return (
|
||||||
|
|
@ -51,33 +48,9 @@ def build_title_system_prompt() -> str:
|
||||||
"story, generate a short title and a one-sentence description for it.\n\n"
|
"story, generate a short title and a one-sentence description for it.\n\n"
|
||||||
"Respond with exactly two lines of plain text:\n"
|
"Respond with exactly two lines of plain text:\n"
|
||||||
"Line 1: the title (max 60 characters, no quotes or labels)\n"
|
"Line 1: the title (max 60 characters, no quotes or labels)\n"
|
||||||
"Line 2: the description (max 200 characters, no quotes or labels)\n\n"
|
"Line 2: the description (max 200 characters, no quotes or labels)"
|
||||||
"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(
|
def build_initial_user_message(
|
||||||
genres: list[str],
|
genres: list[str],
|
||||||
|
|
@ -90,8 +63,18 @@ def build_initial_user_message(
|
||||||
f"- Genre: {', '.join(genres)}\n"
|
f"- Genre: {', '.join(genres)}\n"
|
||||||
f"- Setting: {', '.join(setting)}\n"
|
f"- Setting: {', '.join(setting)}\n"
|
||||||
f"- Vibes: {', '.join(vibes)}\n"
|
f"- Vibes: {', '.join(vibes)}\n"
|
||||||
f"- Protagonist: {', '.join(protagonist)}\n\n"
|
f"- Protagonist: {', '.join(protagonist)}"
|
||||||
"This entry will be used to title the adventure, so include clear hints about the overall story and main character."
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -112,34 +95,38 @@ def build_conversation_messages(
|
||||||
setting: list[str],
|
setting: list[str],
|
||||||
vibes: list[str],
|
vibes: list[str],
|
||||||
protagonist: list[str],
|
protagonist: list[str],
|
||||||
prior_entries_with_choices: list[tuple[AdventureEntry, list[AdventureEntryPossibleChoice], str | None]],
|
prior_entries: list[tuple[AdventureEntry, list[AdventureEntryPossibleChoice], str | None]],
|
||||||
|
prior_decisions: list[AdventureEntryPossibleChoice | None],
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Build the full messages array for an Anthropic API call.
|
"""Build the full messages array for an Anthropic API call.
|
||||||
|
|
||||||
prior_entries is a list of (entry, choices_for_that_entry, selected_choice_id).
|
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.
|
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).
|
For the most recent completed entry it will be None (no choice made yet).
|
||||||
"""
|
"""
|
||||||
messages: list[dict] = [
|
messages: list[dict] = [
|
||||||
{"role": "user", "content": build_initial_user_message(genres, setting, vibes, protagonist)}
|
{"role": "user", "content": build_initial_user_message(genres, setting, vibes, protagonist)}
|
||||||
]
|
]
|
||||||
for entry, choices, selected_choice_id in prior_entries_with_choices:
|
for entry, choices, chosen_label in prior_entries:
|
||||||
|
|
||||||
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(
|
messages.append(
|
||||||
{"role": "assistant", "content": reconstruct_assistant_message(entry, choices)}
|
{"role": "assistant", "content": reconstruct_assistant_message(entry, choices)}
|
||||||
)
|
)
|
||||||
|
|
||||||
messages.append({"role": "user", "content": chosen_choice.label})
|
# 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
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -175,4 +162,12 @@ def parse_entry_response(text: str) -> tuple[str, list[tuple[str, str]], str]:
|
||||||
return story_text, choices, gm_notes
|
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
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,6 @@ class AdventureEntryEntity(Base):
|
||||||
story_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
story_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
gamemaster_notes: 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)
|
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(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,6 @@ def _to_entry(e: AdventureEntryEntity) -> AdventureEntry:
|
||||||
story_text=e.story_text,
|
story_text=e.story_text,
|
||||||
gamemaster_notes=e.gamemaster_notes,
|
gamemaster_notes=e.gamemaster_notes,
|
||||||
llm_data=e.llm_data,
|
llm_data=e.llm_data,
|
||||||
story_text_linguistic_data=e.story_text_linguistic_data,
|
|
||||||
pipeline_timing=e.pipeline_timing,
|
|
||||||
created_at=e.created_at,
|
created_at=e.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -248,22 +246,6 @@ class PostgresAdventureEntryRepository:
|
||||||
await self.db.refresh(entity)
|
await self.db.refresh(entity)
|
||||||
return _to_entry(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:
|
async def count_complete(self, adventure_id: uuid.UUID) -> int:
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(func.count()).select_from(AdventureEntryEntity).where(
|
select(func.count()).select_from(AdventureEntryEntity).where(
|
||||||
|
|
@ -347,27 +329,6 @@ class PostgresAdventureEntryDecisionRepository:
|
||||||
entity = result.scalar_one_or_none()
|
entity = result.scalar_one_or_none()
|
||||||
return _to_decision(entity) if entity else 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:
|
class PostgresAdventureEntryTranslationRepository:
|
||||||
def __init__(self, db: AsyncSession) -> None:
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,7 @@ class SpacyClient:
|
||||||
|
|
||||||
# Recognise line-breaks as always being sentence boundaries, even if the model doesn't.
|
# 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.
|
# This is important for the frontend to be able to show line-breaks in the source text.
|
||||||
# Guard prevents double-registration on the cached nlp object.
|
nlp.add_pipe("sentencizer", before="parser")
|
||||||
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)
|
doc = nlp(text)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ from ...outbound.anthropic.anthropic_client import AnthropicClient
|
||||||
from ...outbound.deepl.deepl_client import DeepLClient
|
from ...outbound.deepl.deepl_client import DeepLClient
|
||||||
from ...outbound.gemini.gemini_client import GeminiClient
|
from ...outbound.gemini.gemini_client import GeminiClient
|
||||||
from ...outbound.postgres.database import AsyncSessionLocal, get_db
|
from ...outbound.postgres.database import AsyncSessionLocal, get_db
|
||||||
from ...outbound.spacy.spacy_client import SpacyClient
|
|
||||||
from ...outbound.postgres.repositories.adventure_repository import (
|
from ...outbound.postgres.repositories.adventure_repository import (
|
||||||
PostgresAdventureEntryAudioRepository,
|
PostgresAdventureEntryAudioRepository,
|
||||||
PostgresAdventureEntryChoiceRepository,
|
PostgresAdventureEntryChoiceRepository,
|
||||||
|
|
@ -92,11 +91,6 @@ class _StubGeminiClient:
|
||||||
return buf.getvalue()
|
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
|
# Service factory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -107,12 +101,10 @@ def _make_service(db: AsyncSession) -> AdventureService:
|
||||||
anthropic = _StubAnthropicClient() # type: ignore[assignment]
|
anthropic = _StubAnthropicClient() # type: ignore[assignment]
|
||||||
deepl = _StubDeepLClient() # type: ignore[assignment]
|
deepl = _StubDeepLClient() # type: ignore[assignment]
|
||||||
gemini = _StubGeminiClient() # type: ignore[assignment]
|
gemini = _StubGeminiClient() # type: ignore[assignment]
|
||||||
spacy = _StubSpacyClient() # type: ignore[assignment]
|
|
||||||
else:
|
else:
|
||||||
anthropic = AnthropicClient.new(settings.anthropic_api_key)
|
anthropic = AnthropicClient.new(settings.anthropic_api_key)
|
||||||
deepl = DeepLClient(settings.deepl_api_key)
|
deepl = DeepLClient(settings.deepl_api_key)
|
||||||
gemini = GeminiClient(settings.gemini_api_key)
|
gemini = GeminiClient(settings.gemini_api_key)
|
||||||
spacy = SpacyClient()
|
|
||||||
|
|
||||||
return AdventureService(
|
return AdventureService(
|
||||||
adventure_repo=PostgresAdventureRepository(db),
|
adventure_repo=PostgresAdventureRepository(db),
|
||||||
|
|
@ -124,7 +116,6 @@ def _make_service(db: AsyncSession) -> AdventureService:
|
||||||
anthropic_client=anthropic,
|
anthropic_client=anthropic,
|
||||||
deepl_client=deepl,
|
deepl_client=deepl,
|
||||||
gemini_client=gemini,
|
gemini_client=gemini,
|
||||||
spacy_client=spacy,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -208,7 +199,6 @@ class EntryDetailResponse(BaseModel):
|
||||||
choices: list[ChoiceResponse]
|
choices: list[ChoiceResponse]
|
||||||
translation: str | None
|
translation: str | None
|
||||||
audio_file_name: str | None
|
audio_file_name: str | None
|
||||||
story_text_linguistic_data: dict | None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -471,5 +461,4 @@ async def get_entry(
|
||||||
],
|
],
|
||||||
translation=translation.translated_text if translation else None,
|
translation=translation.translated_text if translation else None,
|
||||||
audio_file_name=audio.file_name if audio else None,
|
audio_file_name=audio.file_name if audio else None,
|
||||||
story_text_linguistic_data=entry.story_text_linguistic_data,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ class AdventureEntryItem(BaseModel):
|
||||||
status: str
|
status: str
|
||||||
entry_index: int
|
entry_index: int
|
||||||
story_text: str | None
|
story_text: str | None
|
||||||
story_text_linguistic_data: dict | None
|
|
||||||
translation: str | None
|
translation: str | None
|
||||||
audio_url: str | None
|
audio_url: str | None
|
||||||
created_at: str
|
created_at: str
|
||||||
|
|
@ -109,7 +108,6 @@ async def get_adventure(
|
||||||
status=entry.status,
|
status=entry.status,
|
||||||
entry_index=entry.entry_index,
|
entry_index=entry.entry_index,
|
||||||
story_text=entry.story_text,
|
story_text=entry.story_text,
|
||||||
story_text_linguistic_data=entry.story_text_linguistic_data,
|
|
||||||
translation=translation.translated_text if translation else None,
|
translation=translation.translated_text if translation else None,
|
||||||
audio_url=_audio_url(audio.file_name if audio else None),
|
audio_url=_audio_url(audio.file_name if audio else None),
|
||||||
created_at=entry.created_at.isoformat(),
|
created_at=entry.created_at.isoformat(),
|
||||||
|
|
|
||||||
|
|
@ -1,100 +1,174 @@
|
||||||
# Feature design doc: Choose your own adventure
|
# Feature design doc: Choose your own adventure
|
||||||
|
|
||||||
This is a semi-technical design document to detail some enhancements to the _Choose Your Own Adventure_ functionality of the Langauge Learning App.
|
This is a semi-technical design document to detail the *Choose Your Own Adventure* functionality of the Langauge Learning App.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
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.
|
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
|
## Feature Description
|
||||||
|
|
||||||
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.
|
In the website there is a tab, or page, called "Adventures".
|
||||||
|
|
||||||
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).
|
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.
|
||||||
|
|
||||||
You are to change the functionality to:
|
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).
|
||||||
|
|
||||||
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.
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
## Technical components
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
We will need to inject a `SpacyClient` (`app/outbound/spacy/spacy_client`) into the `AdventureService`
|
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.)
|
||||||
|
|
||||||
After the generation of the text (through the call to `anthropic_client.complete` in that method) we should (at a relevant point)
|
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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:
|
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.
|
||||||
|
|
||||||
```json
|
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.
|
||||||
{
|
|
||||||
"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.
|
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.
|
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.
|
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"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -316,12 +316,6 @@ export type AdventureEntryItem = {
|
||||||
* Story Text
|
* Story Text
|
||||||
*/
|
*/
|
||||||
story_text: string | null;
|
story_text: string | null;
|
||||||
/**
|
|
||||||
* Story Text Linguistic Data
|
|
||||||
*/
|
|
||||||
story_text_linguistic_data: {
|
|
||||||
[key: string]: unknown;
|
|
||||||
} | null;
|
|
||||||
/**
|
/**
|
||||||
* Translation
|
* Translation
|
||||||
*/
|
*/
|
||||||
|
|
@ -680,12 +674,6 @@ export type EntryDetailResponse = {
|
||||||
* Audio File Name
|
* Audio File Name
|
||||||
*/
|
*/
|
||||||
audio_file_name: string | null;
|
audio_file_name: string | null;
|
||||||
/**
|
|
||||||
* Story Text Linguistic Data
|
|
||||||
*/
|
|
||||||
story_text_linguistic_data: {
|
|
||||||
[key: string]: unknown;
|
|
||||||
} | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,13 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||||
return error(400, `Error loading adventure`);
|
return error(400, `Error loading adventure`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, entries, current_entry_choices, language, status } = response.data;
|
const { title, entries, current_entry_choices, language } = response.data;
|
||||||
|
|
||||||
|
response.data.entries.forEach((e) => console.log(e.story_text));
|
||||||
return {
|
return {
|
||||||
title: title,
|
title: title,
|
||||||
entries,
|
entries,
|
||||||
choices: current_entry_choices,
|
choices: current_entry_choices,
|
||||||
language: language,
|
language: language
|
||||||
status: status
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,120 +1,18 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import LatestEntry from './LatestEntry.svelte';
|
import LatestEntry from './LatestEntry.svelte';
|
||||||
import PreviousEntries from './PreviousEntries.svelte';
|
import PreviousEntries from './PreviousEntries.svelte';
|
||||||
import { locale, type Locale } from '$lib/i8n';
|
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 { 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 previousEntries = $derived.by(() => {
|
||||||
const entries =
|
const allEntries = data.entries ?? [];
|
||||||
$adventureState.status === 'complete'
|
|
||||||
? $adventureState.entries
|
|
||||||
: $adventureState.entries.slice(0, -1);
|
|
||||||
|
|
||||||
return entries.map((entry, index) => {
|
return allEntries.slice(0, -1).map((entry, index) => {
|
||||||
const nextEntry = entries[index + 1];
|
const nextEntry = allEntries[index + 1];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
|
|
@ -128,143 +26,6 @@
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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(() => {
|
onMount(() => {
|
||||||
$locale = data.language as Locale;
|
$locale = data.language as Locale;
|
||||||
|
|
||||||
|
|
@ -273,40 +34,27 @@
|
||||||
if (latestStoryElement) {
|
if (latestStoryElement) {
|
||||||
latestStoryElement.scrollIntoView({ behavior: 'smooth' });
|
latestStoryElement.scrollIntoView({ behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($adventureState.ui.isWaitingForGeneration) {
|
|
||||||
startPolling();
|
|
||||||
void refreshAdventure();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
stopPolling();
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="adventure-page">
|
<div class="adventure-page">
|
||||||
<header class="adventure-page__header">
|
<header class="adventure-page__header">
|
||||||
<p class="adventure-page__kicker">Choose your own adventure</p>
|
<p class="adventure-page__kicker">Choose your own adventure</p>
|
||||||
<h1 class="adventure-page__title">{adventureTitle}</h1>
|
<h1 class="adventure-page__title">{data.title}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if $adventureState.status === 'awaiting_first_entry'}
|
<PreviousEntries entries={previousEntries} />
|
||||||
<p class="adventure-page__awaiting-entry">Waiting for the first entry...</p>
|
|
||||||
{:else}
|
|
||||||
<PreviousEntries entries={previousEntries} />
|
|
||||||
|
|
||||||
<LatestEntry
|
<LatestEntry
|
||||||
sourceText={latestEntry?.story_text}
|
sourceText={latestEntry.story_text}
|
||||||
translationText={latestEntry?.translation}
|
translationText={latestEntry.translation}
|
||||||
audioUrl={latestEntry?.audio_url}
|
audioUrl={latestEntry.audio_url!}
|
||||||
onSelectNextStep={handleNextStepSelect}
|
nextStepsOptions={data.choices.map((choice) => ({
|
||||||
isWaitingForGeneration={$adventureState.ui.isWaitingForGeneration}
|
label: choice.text,
|
||||||
generationPhase={$adventureState.ui.generationPhase}
|
id: choice.id
|
||||||
statusMessage={$adventureState.ui.statusMessage}
|
}))}
|
||||||
errorMessage={$adventureState.ui.errorMessage}
|
adventureId={params.id}
|
||||||
/>
|
/>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,16 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
import NextSteps from './NextSteps.svelte';
|
import NextSteps from './NextSteps.svelte';
|
||||||
import { adventureState } from './adventureState';
|
import { selectNextStep } from './selectNextStep.remote';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
adventureId: string;
|
||||||
sourceText: string | null | undefined;
|
sourceText: string | null | undefined;
|
||||||
translationText: string | null | undefined;
|
translationText: string | null | undefined;
|
||||||
audioUrl: string | null | undefined;
|
audioUrl: string;
|
||||||
|
nextStepsOptions: { label: string; id: string }[];
|
||||||
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 {
|
const { adventureId, sourceText, translationText, audioUrl, nextStepsOptions }: Props = $props();
|
||||||
sourceText,
|
|
||||||
translationText,
|
|
||||||
audioUrl,
|
|
||||||
|
|
||||||
onSelectNextStep,
|
|
||||||
isWaitingForGeneration,
|
|
||||||
generationPhase,
|
|
||||||
statusMessage,
|
|
||||||
errorMessage
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const sourceParagraphs = $derived.by(() => toParagraphs(sourceText));
|
const sourceParagraphs = $derived.by(() => toParagraphs(sourceText));
|
||||||
const translationParagraphs = $derived.by(() => toParagraphs(translationText));
|
const translationParagraphs = $derived.by(() => toParagraphs(translationText));
|
||||||
|
|
@ -87,10 +66,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function showTranslation() {
|
function showTranslation() {
|
||||||
if (!translationText) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
translationVisible = true;
|
translationVisible = true;
|
||||||
if (translationTimer !== null) {
|
if (translationTimer !== null) {
|
||||||
clearTimeout(translationTimer);
|
clearTimeout(translationTimer);
|
||||||
|
|
@ -106,125 +81,75 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleNextStepSelect(optionId: string) {
|
async function handleNextStepSelect(optionId: string) {
|
||||||
await onSelectNextStep(optionId);
|
const result = await selectNextStep({ adventureId, possibleChoiceId: optionId });
|
||||||
|
console.log({ result });
|
||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (translationTimer !== null) {
|
|
||||||
clearTimeout(translationTimer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $adventureState.status === 'active'}
|
<section class="latest-story" aria-label="Current story entry" id="latest-story">
|
||||||
<section class="latest-story" aria-label="Current story entry" id="latest-story">
|
<header class="latest-story__header">
|
||||||
<header class="latest-story__header">
|
<div class="latest-story__title-group">
|
||||||
<div class="latest-story__title-group">
|
<p class="latest-story__kicker">Current entry</p>
|
||||||
<p class="latest-story__kicker">Current entry</p>
|
<h2 class="latest-story__title">Now reading</h2>
|
||||||
<h2 class="latest-story__title">Now reading</h2>
|
</div>
|
||||||
{#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">
|
<div class="audio-dock" aria-label="Listening controls">
|
||||||
<p class="audio-dock__label">Listen</p>
|
<p class="audio-dock__label">Listen</p>
|
||||||
{#if audioUrl}
|
<audio class="audio-dock__player" controls preload="metadata">
|
||||||
<audio class="audio-dock__player" controls preload="metadata">
|
<source src={audioUrl} type="audio/wav" />
|
||||||
<source src={audioUrl} type="audio/wav" />
|
</audio>
|
||||||
</audio>
|
</div>
|
||||||
{:else}
|
</header>
|
||||||
<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="latest-entry">
|
||||||
<div class="pane source-pane">
|
<div class="pane source-pane">
|
||||||
<div class="latest-entry__pane-body" bind:this={sourcePane} onscroll={handleSourceScroll}>
|
<div class="latest-entry__pane-body" bind:this={sourcePane} onscroll={handleSourceScroll}>
|
||||||
{#if sourceParagraphs.length > 0}
|
{#each sourceParagraphs as paragraph, index (index)}
|
||||||
{#each sourceParagraphs as paragraph, index (index)}
|
<p
|
||||||
<button
|
class="paragraph"
|
||||||
type="button"
|
class:active={lastClickedParagraphIndex === index}
|
||||||
class="paragraph"
|
data-paragraph-index={index}
|
||||||
class:active={lastClickedParagraphIndex === index}
|
data-language="source"
|
||||||
data-paragraph-index={index}
|
onclick={() => handleParagraphClicked(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}
|
|
||||||
>
|
>
|
||||||
{#if translationParagraphs.length > 0}
|
{paragraph}
|
||||||
{#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>
|
</p>
|
||||||
{/if}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<NextSteps
|
<div class="pane translation-pane" data-visible={translationVisible}>
|
||||||
onSelect={handleNextStepSelect}
|
<header class="translation-header">
|
||||||
disabled={isWaitingForGeneration}
|
<p class="translation-header__label">Translation</p>
|
||||||
busyLabel={statusMessage}
|
<button class="dict-toggle" onclick={showTranslation}>
|
||||||
/>
|
<span class="dict-toggle-label">Reveal for 20 seconds</span>
|
||||||
{/if}
|
</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} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.latest-story {
|
.latest-story {
|
||||||
|
|
@ -263,36 +188,6 @@
|
||||||
color: var(--color-on-surface);
|
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 {
|
.audio-dock {
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
|
|
@ -317,25 +212,6 @@
|
||||||
accent-color: var(--color-primary);
|
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 {
|
.latest-entry {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
|
@ -419,12 +295,6 @@
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dict-toggle:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dict-toggle-label {
|
.dict-toggle-label {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
@ -450,67 +320,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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 {
|
.latest-entry__pane-body::-webkit-scrollbar {
|
||||||
width: 0.75rem;
|
width: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
@ -527,12 +336,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.paragraph {
|
.paragraph {
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
text-align: left;
|
|
||||||
font-size: clamp(1.1rem, 1rem + 0.35vw, 1.35rem);
|
font-size: clamp(1.1rem, 1rem + 0.35vw, 1.35rem);
|
||||||
line-height: var(--leading-relaxed);
|
line-height: var(--leading-relaxed);
|
||||||
color: var(--color-on-surface);
|
color: var(--color-on-surface);
|
||||||
|
|
@ -547,11 +350,6 @@
|
||||||
border-radius: var(--radius-md);
|
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 {
|
.paragraph + .paragraph {
|
||||||
margin-top: var(--space-3);
|
margin-top: var(--space-3);
|
||||||
padding-top: var(--space-3);
|
padding-top: var(--space-3);
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { adventureState } from './adventureState';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
options: { label: string; id: string }[];
|
||||||
onSelect: (optionId: string) => void;
|
onSelect: (optionId: string) => void;
|
||||||
disabled?: boolean;
|
|
||||||
busyLabel?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const { options, onSelect }: Props = $props();
|
||||||
onSelect,
|
|
||||||
disabled = false,
|
|
||||||
busyLabel = 'Generating your next entry...'
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let isSubmitting = $state(false);
|
let isSubmitting = $state(false);
|
||||||
|
|
||||||
|
|
@ -29,35 +22,30 @@
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $adventureState.status !== 'complete'}
|
<section class="next-steps" aria-label="Choose what happens next">
|
||||||
<section class="next-steps" aria-label="Choose what happens next">
|
<header class="next-steps__header">
|
||||||
<header class="next-steps__header">
|
<p class="next-steps__kicker">Choose your path</p>
|
||||||
<p class="next-steps__kicker">Choose your path</p>
|
<h2 class="next-steps__title">What happens next?</h2>
|
||||||
<h2 class="next-steps__title">What happens next?</h2>
|
</header>
|
||||||
{#if disabled}
|
|
||||||
<p class="next-steps__status" role="status" aria-live="polite">{busyLabel}</p>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<ol class="next-steps__list">
|
<ol class="next-steps__list">
|
||||||
{#each $adventureState.nextStepsOptions as option, index (option.id)}
|
{#each options as option, index (option.id)}
|
||||||
<li class="next-steps__item">
|
<li class="next-steps__item">
|
||||||
<button
|
<button
|
||||||
class="next-steps__button"
|
class="next-steps__button"
|
||||||
onclick={() => handleOptionSelect(option.id)}
|
onclick={() => handleOptionSelect(option.id)}
|
||||||
disabled={isSubmitting || disabled}
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<span class="next-steps__index" aria-hidden="true"
|
||||||
|
>{String(index + 1).padStart(2, '0')}</span
|
||||||
>
|
>
|
||||||
<span class="next-steps__index" aria-hidden="true"
|
<span class="next-steps__label">{option.label}</span>
|
||||||
>{String(index + 1).padStart(2, '0')}</span
|
<span class="next-steps__meta" aria-hidden="true">Choose</span>
|
||||||
>
|
</button>
|
||||||
<span class="next-steps__label">{option.label}</span>
|
</li>
|
||||||
<span class="next-steps__meta" aria-hidden="true">Choose</span>
|
{/each}
|
||||||
</button>
|
</ol>
|
||||||
</li>
|
</section>
|
||||||
{/each}
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.next-steps {
|
.next-steps {
|
||||||
|
|
@ -92,15 +80,6 @@
|
||||||
color: var(--color-on-surface);
|
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 {
|
.next-steps__list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-card__choices {
|
.entry-card__choices {
|
||||||
width: 100%;
|
|
||||||
max-width: 65ch;
|
max-width: 65ch;
|
||||||
font-size: var(--text-body-md);
|
font-size: var(--text-body-md);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
@ -6,95 +6,63 @@ import { randomItemInArray, shuffleArray } from '$lib';
|
||||||
import { formatLanguage } from '$lib/formatters';
|
import { formatLanguage } from '$lib/formatters';
|
||||||
|
|
||||||
const allVibes = [
|
const allVibes = [
|
||||||
'A single night',
|
|
||||||
'Academia',
|
|
||||||
'Animal companions',
|
|
||||||
'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',
|
'Melancholic',
|
||||||
'Melodrama',
|
'Gothic',
|
||||||
'Mentor and student',
|
|
||||||
'Mystery box',
|
|
||||||
'Nordic',
|
|
||||||
'North America',
|
|
||||||
'Northern Africa',
|
|
||||||
'Paranormal',
|
|
||||||
'Parenthood',
|
|
||||||
'Parenthood',
|
|
||||||
'Plot twist near the end',
|
|
||||||
'Political',
|
|
||||||
'Post-apocalyptic',
|
|
||||||
'Propulsive',
|
|
||||||
'Pulp',
|
|
||||||
'Queer-norm',
|
|
||||||
'Recovery',
|
|
||||||
'Redemption',
|
|
||||||
'Reluctant hero',
|
|
||||||
'Road trip',
|
|
||||||
'Slapstick',
|
|
||||||
'Sly',
|
|
||||||
'Small town',
|
|
||||||
'Southeast Asia',
|
|
||||||
'Southern Africa',
|
|
||||||
'Spy thriller',
|
|
||||||
'Starving artist',
|
|
||||||
'Sun-drenched',
|
'Sun-drenched',
|
||||||
'Survival',
|
'Bleak',
|
||||||
'Tense',
|
|
||||||
'The sea',
|
|
||||||
'Time travel',
|
|
||||||
'Tropical',
|
|
||||||
'Unlikely duo',
|
|
||||||
'Unreliable narrator',
|
|
||||||
'War',
|
|
||||||
'West Asia',
|
|
||||||
'Western Africa',
|
|
||||||
'Whimsical',
|
'Whimsical',
|
||||||
'Witty'
|
'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',
|
||||||
|
'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',
|
||||||
|
'Post-apocalyptic',
|
||||||
|
'Survival',
|
||||||
|
'War',
|
||||||
|
'Spy thriller',
|
||||||
|
'Time travel'
|
||||||
];
|
];
|
||||||
|
|
||||||
const allGenres = [
|
const allGenres = [
|
||||||
'Adventure',
|
|
||||||
'Crime Fiction',
|
'Crime Fiction',
|
||||||
'Crime noir',
|
'Crime noir',
|
||||||
'Family',
|
'Who-dun-it mystery',
|
||||||
'Fantasy',
|
|
||||||
'Horror',
|
|
||||||
'Mystery',
|
|
||||||
'Paranormal',
|
'Paranormal',
|
||||||
|
'Horror',
|
||||||
'Psychological thriller',
|
'Psychological thriller',
|
||||||
'Romance',
|
'Romance',
|
||||||
'Science Fiction',
|
'Family',
|
||||||
'Thriller',
|
'Fantasy',
|
||||||
'Who-dun-it mystery'
|
'Science Fiction'
|
||||||
];
|
];
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
let languageCode = 'fr';
|
let languageCode = 'fr';
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { getJobsApiJobsGet } from '../../../client/sdk.gen.ts';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
const authToken = locals.authToken;
|
const authToken = locals.authToken;
|
||||||
|
console.log({ authToken });
|
||||||
client.setConfig({
|
client.setConfig({
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${authToken}`
|
Authorization: `Bearer ${authToken}`
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue