Compare commits
4 commits
8b687e9737
...
a8cd8d8060
| Author | SHA1 | Date | |
|---|---|---|---|
| a8cd8d8060 | |||
| ac73bd1a04 | |||
| 2d5933ff59 | |||
| a5f2f630fe |
15 changed files with 1018 additions and 38 deletions
|
|
@ -1,6 +1,7 @@
|
|||
import logging
|
||||
import uuid
|
||||
|
||||
from ...languages import SUPPORTED_LANGUAGES
|
||||
from ...outbound.anthropic.adventure_prompts import (
|
||||
build_conversation_messages,
|
||||
build_entry_system_prompt,
|
||||
|
|
@ -21,8 +22,11 @@ from ...outbound.postgres.repositories.adventure_repository import (
|
|||
PostgresAdventureRepository,
|
||||
)
|
||||
from ...storage import upload_audio
|
||||
from ..models.adventure import Adventure, AdventureEntry, AdventureEntryPossibleChoiceDecision
|
||||
from ...languages import SUPPORTED_LANGUAGES
|
||||
from ..models.adventure import (
|
||||
Adventure,
|
||||
AdventureEntry,
|
||||
AdventureEntryPossibleChoiceDecision,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -60,6 +64,7 @@ class AdventureService:
|
|||
setting: list[str],
|
||||
vibes: list[str],
|
||||
protagonist: list[str],
|
||||
entry_word_count_range: list[int],
|
||||
max_entry_count: int = 6,
|
||||
) -> tuple[Adventure, AdventureEntry]:
|
||||
"""Creates the adventure and a placeholder for the first entry.
|
||||
|
|
@ -76,7 +81,10 @@ class AdventureService:
|
|||
vibes=vibes,
|
||||
protagonist=protagonist,
|
||||
max_entry_count=max_entry_count,
|
||||
entry_story_text_target_length={"min": 700, "max": 800},
|
||||
entry_story_text_target_length={
|
||||
"min": entry_word_count_range[0],
|
||||
"max": entry_word_count_range[1],
|
||||
},
|
||||
)
|
||||
first_entry = await self.entry_repo.create(
|
||||
adventure_id=uuid.UUID(adventure.id),
|
||||
|
|
@ -154,10 +162,14 @@ class AdventureService:
|
|||
is_final_entry = current_entry.entry_index + 1 == adventure.max_entry_count
|
||||
|
||||
prior_entries = await self._load_prior_entries_with_metadata(
|
||||
all_entries=[e for e in all_entries if e.entry_index < current_entry.entry_index],
|
||||
all_entries=[
|
||||
e for e in all_entries if e.entry_index < current_entry.entry_index
|
||||
],
|
||||
)
|
||||
|
||||
language_name = SUPPORTED_LANGUAGES.get(adventure.language, adventure.language)
|
||||
language_name = SUPPORTED_LANGUAGES.get(
|
||||
adventure.language, adventure.language
|
||||
)
|
||||
competency = adventure.competencies[0] if adventure.competencies else "B1"
|
||||
system_prompt = build_entry_system_prompt(
|
||||
language_name=language_name,
|
||||
|
|
@ -193,10 +205,15 @@ class AdventureService:
|
|||
if not is_final_entry:
|
||||
await self.choice_repo.create_many(
|
||||
entry_id=entry_id,
|
||||
choices=[(i, label, text) for i, (label, text) in enumerate(choices_parsed)],
|
||||
choices=[
|
||||
(i, label, text)
|
||||
for i, (label, text) in enumerate(choices_parsed)
|
||||
],
|
||||
)
|
||||
|
||||
translated = await self.deepl_client.translate(story_text, adventure.source_language)
|
||||
translated = await self.deepl_client.translate(
|
||||
story_text, adventure.source_language
|
||||
)
|
||||
await self.translation_repo.create(
|
||||
entry_id=entry_id,
|
||||
component_type="story_text",
|
||||
|
|
@ -218,7 +235,9 @@ class AdventureService:
|
|||
|
||||
if is_first_entry:
|
||||
title_system = build_title_system_prompt()
|
||||
title_user = build_title_user_message(story_text, language_name, adventure.genres)
|
||||
title_user = build_title_user_message(
|
||||
story_text, language_name, adventure.genres
|
||||
)
|
||||
title_raw, _ = await self.anthropic_client.complete(
|
||||
system_prompt=title_system,
|
||||
messages=[{"role": "user", "content": title_user}],
|
||||
|
|
@ -230,13 +249,17 @@ class AdventureService:
|
|||
)
|
||||
|
||||
new_status = "complete" if is_final_entry else "active"
|
||||
await self.adventure_repo.update_status(adventure_id=adventure_id, status=new_status)
|
||||
await self.adventure_repo.update_status(
|
||||
adventure_id=adventure_id, status=new_status
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Entry pipeline failed for entry %s", entry_id)
|
||||
try:
|
||||
await self.entry_repo.update_status(entry_id=entry_id, status="error")
|
||||
await self.adventure_repo.update_status(adventure_id=adventure_id, status="error")
|
||||
await self.adventure_repo.update_status(
|
||||
adventure_id=adventure_id, status="error"
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to mark entry/adventure as error")
|
||||
|
||||
|
|
@ -258,7 +281,11 @@ class AdventureService:
|
|||
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),
|
||||
(
|
||||
c
|
||||
for c in choices
|
||||
if c.id == next_entry.generated_from_choice_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if chosen:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ... import worker
|
||||
from ...auth import verify_token
|
||||
from ...config import settings
|
||||
from ...domain.services.adventure_service import AdventureService
|
||||
|
|
@ -23,7 +24,6 @@ from ...outbound.postgres.repositories.adventure_repository import (
|
|||
PostgresAdventureEntryTranslationRepository,
|
||||
PostgresAdventureRepository,
|
||||
)
|
||||
from ... import worker
|
||||
|
||||
router = APIRouter(prefix="/adventures", tags=["adventures"])
|
||||
|
||||
|
|
@ -44,8 +44,7 @@ _STUB_ENTRY_RESPONSE = (
|
|||
"no notes"
|
||||
)
|
||||
_STUB_TITLE_RESPONSE = (
|
||||
"La Nuit Parisienne\n"
|
||||
"Une aventure mystérieuse dans les rues sombres de Paris."
|
||||
"La Nuit Parisienne\nUne aventure mystérieuse dans les rues sombres de Paris."
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -57,7 +56,12 @@ class _StubAnthropicClient:
|
|||
model: str = "claude-sonnet-4-6",
|
||||
max_tokens: int = 2048,
|
||||
) -> tuple[str, dict]:
|
||||
usage = {"provider": "stub", "model": "stub", "input_tokens": 0, "output_tokens": 0}
|
||||
usage = {
|
||||
"provider": "stub",
|
||||
"model": "stub",
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
}
|
||||
if "game master" in system_prompt.lower():
|
||||
return _STUB_ENTRY_RESPONSE, usage
|
||||
return _STUB_TITLE_RESPONSE, usage
|
||||
|
|
@ -67,7 +71,9 @@ class _StubDeepLClient:
|
|||
def can_translate_to(self, lang: str) -> bool:
|
||||
return True
|
||||
|
||||
async def translate(self, text: str, to_language: str, context: str | None = None) -> str:
|
||||
async def translate(
|
||||
self, text: str, to_language: str, context: str | None = None
|
||||
) -> str:
|
||||
return f"[STUB] {text[:120]}"
|
||||
|
||||
|
||||
|
|
@ -89,6 +95,7 @@ class _StubGeminiClient:
|
|||
# Service factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_service(db: AsyncSession) -> AdventureService:
|
||||
if settings.stub_generation:
|
||||
anthropic = _StubAnthropicClient() # type: ignore[assignment]
|
||||
|
|
@ -123,6 +130,7 @@ async def _run_entry_pipeline_task(
|
|||
# Request / response models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CreateAdventureRequest(BaseModel):
|
||||
language: str
|
||||
source_language: str
|
||||
|
|
@ -131,6 +139,7 @@ class CreateAdventureRequest(BaseModel):
|
|||
setting: list[str]
|
||||
vibes: list[str]
|
||||
protagonist: list[str]
|
||||
entry_word_count_range: str
|
||||
max_entry_count: int = 6
|
||||
|
||||
|
||||
|
|
@ -196,6 +205,7 @@ class EntryDetailResponse(BaseModel):
|
|||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _to_adventure_response(adventure) -> AdventureResponse:
|
||||
return AdventureResponse(
|
||||
id=adventure.id,
|
||||
|
|
@ -226,6 +236,7 @@ def _parse_adventure_id(adventure_id: str) -> uuid.UUID:
|
|||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("", response_model=AdventureResponse, status_code=201)
|
||||
async def create_adventure(
|
||||
body: CreateAdventureRequest,
|
||||
|
|
@ -240,13 +251,30 @@ async def create_adventure(
|
|||
detail=f"Unsupported language '{body.language}'. Supported: {list(SUPPORTED_LANGUAGES)}",
|
||||
)
|
||||
|
||||
deepl_client = DeepLClient(settings.deepl_api_key) if not settings.stub_generation else _StubDeepLClient() # type: ignore[assignment]
|
||||
deepl_client = (
|
||||
DeepLClient(settings.deepl_api_key)
|
||||
if not settings.stub_generation
|
||||
else _StubDeepLClient()
|
||||
) # type: ignore[assignment]
|
||||
if not deepl_client.can_translate_to(body.source_language):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot translate to source language '{body.source_language}'",
|
||||
)
|
||||
|
||||
# Word count is e.g. "100-200 Words", convert to a tuple of ints (100, 200)
|
||||
try:
|
||||
word_count_range = tuple(
|
||||
int(x.strip().split()[0]) for x in body.entry_word_count_range.split("-")
|
||||
)
|
||||
if len(word_count_range) != 2 or word_count_range[0] >= word_count_range[1]:
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid entry_word_count_range. Expected format 'min-max Words', e.g. '100-200 Words'",
|
||||
)
|
||||
|
||||
adventure, first_entry = await _make_service(db).create_adventure_for_user(
|
||||
user_id=user_id,
|
||||
language=body.language,
|
||||
|
|
@ -257,9 +285,12 @@ async def create_adventure(
|
|||
vibes=body.vibes,
|
||||
protagonist=body.protagonist,
|
||||
max_entry_count=body.max_entry_count,
|
||||
entry_word_count_range=word_count_range,
|
||||
)
|
||||
await worker.enqueue(
|
||||
partial(_run_entry_pipeline_task, uuid.UUID(adventure.id), uuid.UUID(first_entry.id))
|
||||
partial(
|
||||
_run_entry_pipeline_task, uuid.UUID(adventure.id), uuid.UUID(first_entry.id)
|
||||
)
|
||||
)
|
||||
return _to_adventure_response(adventure)
|
||||
|
||||
|
|
@ -281,7 +312,9 @@ async def get_adventure(
|
|||
token_data: dict = Depends(verify_token),
|
||||
) -> AdventureResponse:
|
||||
user_id = uuid.UUID(token_data["sub"])
|
||||
adventure = await PostgresAdventureRepository(db).get_by_id(_parse_adventure_id(adventure_id))
|
||||
adventure = await PostgresAdventureRepository(db).get_by_id(
|
||||
_parse_adventure_id(adventure_id)
|
||||
)
|
||||
if adventure is None or adventure.user_id != str(user_id):
|
||||
raise HTTPException(status_code=404, detail="Adventure not found")
|
||||
return _to_adventure_response(adventure)
|
||||
|
|
@ -301,7 +334,9 @@ async def delete_adventure(
|
|||
await repo.soft_delete(uuid.UUID(adventure.id))
|
||||
|
||||
|
||||
@router.post("/{adventure_id}/decisions", response_model=DecisionResponse, status_code=201)
|
||||
@router.post(
|
||||
"/{adventure_id}/decisions", response_model=DecisionResponse, status_code=201
|
||||
)
|
||||
async def record_decision(
|
||||
adventure_id: str,
|
||||
body: CreateDecisionRequest,
|
||||
|
|
@ -316,7 +351,9 @@ async def record_decision(
|
|||
raise HTTPException(status_code=400, detail="Invalid choice_id")
|
||||
|
||||
try:
|
||||
decision, next_entry = await _make_service(db).record_decision_and_prepare_next_entry(
|
||||
decision, next_entry = await _make_service(
|
||||
db
|
||||
).record_decision_and_prepare_next_entry(
|
||||
adventure_id=_parse_adventure_id(adventure_id),
|
||||
choice_id=choice_id,
|
||||
user_id=user_id,
|
||||
|
|
@ -418,7 +455,8 @@ async def get_entry(
|
|||
story_text=entry.story_text,
|
||||
created_at=entry.created_at.isoformat(),
|
||||
choices=[
|
||||
ChoiceResponse(id=c.id, index=c.index, label=c.label, text=c.text) for c in choices
|
||||
ChoiceResponse(id=c.id, index=c.index, label=c.label, text=c.text)
|
||||
for c in choices
|
||||
],
|
||||
translation=translation.translated_text if translation else None,
|
||||
audio_file_name=audio.file_name if audio else None,
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -312,6 +312,12 @@ body {
|
|||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
|
||||
.field-fieldset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.field-input {
|
||||
background-color: var(--color-surface-container-high);
|
||||
color: var(--color-on-surface);
|
||||
|
|
@ -516,3 +522,24 @@ body {
|
|||
color: #b3261e;
|
||||
border-left: 3px solid #b3261e;
|
||||
}
|
||||
|
||||
/*
|
||||
LAYOUT: APP PAGE
|
||||
*/
|
||||
|
||||
.app-page {
|
||||
max-width: 52rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-8) var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
|
||||
.page-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-headline-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
line-height: var(--leading-snug);
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -192,6 +192,68 @@ export type AddWordRequest = {
|
|||
source_article_id?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* AdventureResponse
|
||||
*/
|
||||
export type AdventureResponse = {
|
||||
/**
|
||||
* Id
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* User Id
|
||||
*/
|
||||
user_id: string;
|
||||
/**
|
||||
* Status
|
||||
*/
|
||||
status: string;
|
||||
/**
|
||||
* Language
|
||||
*/
|
||||
language: string;
|
||||
/**
|
||||
* Source Language
|
||||
*/
|
||||
source_language: string;
|
||||
/**
|
||||
* Competencies
|
||||
*/
|
||||
competencies: Array<string>;
|
||||
/**
|
||||
* Max Entry Count
|
||||
*/
|
||||
max_entry_count: number;
|
||||
/**
|
||||
* Title
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Description
|
||||
*/
|
||||
description: string | null;
|
||||
/**
|
||||
* Genres
|
||||
*/
|
||||
genres: Array<string>;
|
||||
/**
|
||||
* Setting
|
||||
*/
|
||||
setting: Array<string>;
|
||||
/**
|
||||
* Vibes
|
||||
*/
|
||||
vibes: Array<string>;
|
||||
/**
|
||||
* Protagonist
|
||||
*/
|
||||
protagonist: Array<string>;
|
||||
/**
|
||||
* Created At
|
||||
*/
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* ArticleDetail
|
||||
*/
|
||||
|
|
@ -300,6 +362,76 @@ export type ArticleListResponse = {
|
|||
articles: Array<ArticleItem>;
|
||||
};
|
||||
|
||||
/**
|
||||
* ChoiceResponse
|
||||
*/
|
||||
export type ChoiceResponse = {
|
||||
/**
|
||||
* Id
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Index
|
||||
*/
|
||||
index: number;
|
||||
/**
|
||||
* Label
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Text
|
||||
*/
|
||||
text: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* CreateAdventureRequest
|
||||
*/
|
||||
export type CreateAdventureRequest = {
|
||||
/**
|
||||
* Language
|
||||
*/
|
||||
language: string;
|
||||
/**
|
||||
* Source Language
|
||||
*/
|
||||
source_language: string;
|
||||
/**
|
||||
* Competencies
|
||||
*/
|
||||
competencies: Array<string>;
|
||||
/**
|
||||
* Genres
|
||||
*/
|
||||
genres: Array<string>;
|
||||
/**
|
||||
* Setting
|
||||
*/
|
||||
setting: Array<string>;
|
||||
/**
|
||||
* Vibes
|
||||
*/
|
||||
vibes: Array<string>;
|
||||
/**
|
||||
* Protagonist
|
||||
*/
|
||||
protagonist: Array<string>;
|
||||
/**
|
||||
* Max Entry Count
|
||||
*/
|
||||
max_entry_count?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* CreateDecisionRequest
|
||||
*/
|
||||
export type CreateDecisionRequest = {
|
||||
/**
|
||||
* Choice Id
|
||||
*/
|
||||
choice_id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* CreatePackRequest
|
||||
*/
|
||||
|
|
@ -334,6 +466,108 @@ export type CreatePackRequest = {
|
|||
proficiencies?: Array<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* DecisionResponse
|
||||
*/
|
||||
export type DecisionResponse = {
|
||||
/**
|
||||
* Id
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Choice Id
|
||||
*/
|
||||
choice_id: string;
|
||||
/**
|
||||
* User Id
|
||||
*/
|
||||
user_id: string;
|
||||
/**
|
||||
* Created At
|
||||
*/
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* EntryDetailResponse
|
||||
*/
|
||||
export type EntryDetailResponse = {
|
||||
/**
|
||||
* Id
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Adventure Id
|
||||
*/
|
||||
adventure_id: string;
|
||||
/**
|
||||
* Generated From Choice Id
|
||||
*/
|
||||
generated_from_choice_id: string | null;
|
||||
/**
|
||||
* Status
|
||||
*/
|
||||
status: string;
|
||||
/**
|
||||
* Entry Index
|
||||
*/
|
||||
entry_index: number;
|
||||
/**
|
||||
* Story Text
|
||||
*/
|
||||
story_text: string | null;
|
||||
/**
|
||||
* Created At
|
||||
*/
|
||||
created_at: string;
|
||||
/**
|
||||
* Choices
|
||||
*/
|
||||
choices: Array<ChoiceResponse>;
|
||||
/**
|
||||
* Translation
|
||||
*/
|
||||
translation: string | null;
|
||||
/**
|
||||
* Audio File Name
|
||||
*/
|
||||
audio_file_name: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* EntryResponse
|
||||
*/
|
||||
export type EntryResponse = {
|
||||
/**
|
||||
* Id
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* Adventure Id
|
||||
*/
|
||||
adventure_id: string;
|
||||
/**
|
||||
* Generated From Choice Id
|
||||
*/
|
||||
generated_from_choice_id: string | null;
|
||||
/**
|
||||
* Status
|
||||
*/
|
||||
status: string;
|
||||
/**
|
||||
* Entry Index
|
||||
*/
|
||||
entry_index: number;
|
||||
/**
|
||||
* Story Text
|
||||
*/
|
||||
story_text: string | null;
|
||||
/**
|
||||
* Created At
|
||||
*/
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* FlashcardEventResponse
|
||||
*/
|
||||
|
|
@ -1019,6 +1253,14 @@ export type SenseCandidateResponse = {
|
|||
tags: Array<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* SenseMatch
|
||||
*/
|
||||
export type SenseMatch = {
|
||||
sense: SenseResponse;
|
||||
lemma: LemmaResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* SenseResponse
|
||||
*/
|
||||
|
|
@ -1558,6 +1800,42 @@ export type SearchWordformsPrefixApiDictionarySearchGetResponses = {
|
|||
|
||||
export type SearchWordformsPrefixApiDictionarySearchGetResponse = SearchWordformsPrefixApiDictionarySearchGetResponses[keyof SearchWordformsPrefixApiDictionarySearchGetResponses];
|
||||
|
||||
export type SearchSensesApiDictionarySensesGetData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query: {
|
||||
/**
|
||||
* Lang Code
|
||||
*/
|
||||
lang_code: string;
|
||||
/**
|
||||
* Text
|
||||
*/
|
||||
text: string;
|
||||
};
|
||||
url: '/api/dictionary/senses';
|
||||
};
|
||||
|
||||
export type SearchSensesApiDictionarySensesGetErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type SearchSensesApiDictionarySensesGetError = SearchSensesApiDictionarySensesGetErrors[keyof SearchSensesApiDictionarySensesGetErrors];
|
||||
|
||||
export type SearchSensesApiDictionarySensesGetResponses = {
|
||||
/**
|
||||
* Response Search Senses Api Dictionary Senses Get
|
||||
*
|
||||
* Successful Response
|
||||
*/
|
||||
200: Array<SenseMatch>;
|
||||
};
|
||||
|
||||
export type SearchSensesApiDictionarySensesGetResponse = SearchSensesApiDictionarySensesGetResponses[keyof SearchSensesApiDictionarySensesGetResponses];
|
||||
|
||||
export type SearchWordformsApiDictionaryWordformsGetData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
|
@ -2380,6 +2658,205 @@ export type RemoveFlashcardTemplateApiAdminPacksPackIdEntriesEntryIdFlashcardsTe
|
|||
|
||||
export type RemoveFlashcardTemplateApiAdminPacksPackIdEntriesEntryIdFlashcardsTemplateIdDeleteResponse = RemoveFlashcardTemplateApiAdminPacksPackIdEntriesEntryIdFlashcardsTemplateIdDeleteResponses[keyof RemoveFlashcardTemplateApiAdminPacksPackIdEntriesEntryIdFlashcardsTemplateIdDeleteResponses];
|
||||
|
||||
export type ListAdventuresApiAdventuresGetData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/adventures';
|
||||
};
|
||||
|
||||
export type ListAdventuresApiAdventuresGetResponses = {
|
||||
/**
|
||||
* Response List Adventures Api Adventures Get
|
||||
*
|
||||
* Successful Response
|
||||
*/
|
||||
200: Array<AdventureResponse>;
|
||||
};
|
||||
|
||||
export type ListAdventuresApiAdventuresGetResponse = ListAdventuresApiAdventuresGetResponses[keyof ListAdventuresApiAdventuresGetResponses];
|
||||
|
||||
export type CreateAdventureApiAdventuresPostData = {
|
||||
body: CreateAdventureRequest;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/api/adventures';
|
||||
};
|
||||
|
||||
export type CreateAdventureApiAdventuresPostErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type CreateAdventureApiAdventuresPostError = CreateAdventureApiAdventuresPostErrors[keyof CreateAdventureApiAdventuresPostErrors];
|
||||
|
||||
export type CreateAdventureApiAdventuresPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
201: AdventureResponse;
|
||||
};
|
||||
|
||||
export type CreateAdventureApiAdventuresPostResponse = CreateAdventureApiAdventuresPostResponses[keyof CreateAdventureApiAdventuresPostResponses];
|
||||
|
||||
export type DeleteAdventureApiAdventuresAdventureIdDeleteData = {
|
||||
body?: never;
|
||||
path: {
|
||||
/**
|
||||
* Adventure Id
|
||||
*/
|
||||
adventure_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/adventures/{adventure_id}';
|
||||
};
|
||||
|
||||
export type DeleteAdventureApiAdventuresAdventureIdDeleteErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type DeleteAdventureApiAdventuresAdventureIdDeleteError = DeleteAdventureApiAdventuresAdventureIdDeleteErrors[keyof DeleteAdventureApiAdventuresAdventureIdDeleteErrors];
|
||||
|
||||
export type DeleteAdventureApiAdventuresAdventureIdDeleteResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
204: void;
|
||||
};
|
||||
|
||||
export type DeleteAdventureApiAdventuresAdventureIdDeleteResponse = DeleteAdventureApiAdventuresAdventureIdDeleteResponses[keyof DeleteAdventureApiAdventuresAdventureIdDeleteResponses];
|
||||
|
||||
export type GetAdventureApiAdventuresAdventureIdGetData = {
|
||||
body?: never;
|
||||
path: {
|
||||
/**
|
||||
* Adventure Id
|
||||
*/
|
||||
adventure_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/adventures/{adventure_id}';
|
||||
};
|
||||
|
||||
export type GetAdventureApiAdventuresAdventureIdGetErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetAdventureApiAdventuresAdventureIdGetError = GetAdventureApiAdventuresAdventureIdGetErrors[keyof GetAdventureApiAdventuresAdventureIdGetErrors];
|
||||
|
||||
export type GetAdventureApiAdventuresAdventureIdGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: AdventureResponse;
|
||||
};
|
||||
|
||||
export type GetAdventureApiAdventuresAdventureIdGetResponse = GetAdventureApiAdventuresAdventureIdGetResponses[keyof GetAdventureApiAdventuresAdventureIdGetResponses];
|
||||
|
||||
export type RecordDecisionApiAdventuresAdventureIdDecisionsPostData = {
|
||||
body: CreateDecisionRequest;
|
||||
path: {
|
||||
/**
|
||||
* Adventure Id
|
||||
*/
|
||||
adventure_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/adventures/{adventure_id}/decisions';
|
||||
};
|
||||
|
||||
export type RecordDecisionApiAdventuresAdventureIdDecisionsPostErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type RecordDecisionApiAdventuresAdventureIdDecisionsPostError = RecordDecisionApiAdventuresAdventureIdDecisionsPostErrors[keyof RecordDecisionApiAdventuresAdventureIdDecisionsPostErrors];
|
||||
|
||||
export type RecordDecisionApiAdventuresAdventureIdDecisionsPostResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
201: DecisionResponse;
|
||||
};
|
||||
|
||||
export type RecordDecisionApiAdventuresAdventureIdDecisionsPostResponse = RecordDecisionApiAdventuresAdventureIdDecisionsPostResponses[keyof RecordDecisionApiAdventuresAdventureIdDecisionsPostResponses];
|
||||
|
||||
export type ListEntriesApiAdventuresAdventureIdEntriesGetData = {
|
||||
body?: never;
|
||||
path: {
|
||||
/**
|
||||
* Adventure Id
|
||||
*/
|
||||
adventure_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/adventures/{adventure_id}/entries';
|
||||
};
|
||||
|
||||
export type ListEntriesApiAdventuresAdventureIdEntriesGetErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type ListEntriesApiAdventuresAdventureIdEntriesGetError = ListEntriesApiAdventuresAdventureIdEntriesGetErrors[keyof ListEntriesApiAdventuresAdventureIdEntriesGetErrors];
|
||||
|
||||
export type ListEntriesApiAdventuresAdventureIdEntriesGetResponses = {
|
||||
/**
|
||||
* Response List Entries Api Adventures Adventure Id Entries Get
|
||||
*
|
||||
* Successful Response
|
||||
*/
|
||||
200: Array<EntryResponse>;
|
||||
};
|
||||
|
||||
export type ListEntriesApiAdventuresAdventureIdEntriesGetResponse = ListEntriesApiAdventuresAdventureIdEntriesGetResponses[keyof ListEntriesApiAdventuresAdventureIdEntriesGetResponses];
|
||||
|
||||
export type GetEntryApiAdventuresAdventureIdEntriesEntryIdGetData = {
|
||||
body?: never;
|
||||
path: {
|
||||
/**
|
||||
* Adventure Id
|
||||
*/
|
||||
adventure_id: string;
|
||||
/**
|
||||
* Entry Id
|
||||
*/
|
||||
entry_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/api/adventures/{adventure_id}/entries/{entry_id}';
|
||||
};
|
||||
|
||||
export type GetEntryApiAdventuresAdventureIdEntriesEntryIdGetErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetEntryApiAdventuresAdventureIdEntriesEntryIdGetError = GetEntryApiAdventuresAdventureIdEntriesEntryIdGetErrors[keyof GetEntryApiAdventuresAdventureIdEntriesEntryIdGetErrors];
|
||||
|
||||
export type GetEntryApiAdventuresAdventureIdEntriesEntryIdGetResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: EntryDetailResponse;
|
||||
};
|
||||
|
||||
export type GetEntryApiAdventuresAdventureIdEntriesEntryIdGetResponse = GetEntryApiAdventuresAdventureIdEntriesEntryIdGetResponses[keyof GetEntryApiAdventuresAdventureIdEntriesEntryIdGetResponses];
|
||||
|
||||
export type GetAccountBffAccountGetData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
// place files you want to import through the `$lib` alias in this folder.
|
||||
export { shuffleArray } from './shuffleArray';
|
||||
|
|
|
|||
3
frontend/src/lib/shuffleArray.ts
Normal file
3
frontend/src/lib/shuffleArray.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export function shuffleArray<T>(data: Array<T>): Array<T> {
|
||||
return data.sort(() => Math.random() - 0.5);
|
||||
}
|
||||
28
frontend/src/routes/app/adventures/+page.svelte
Normal file
28
frontend/src/routes/app/adventures/+page.svelte
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<script>
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import { getAdventures } from './getAdventures.remote';
|
||||
onMount(async () => {
|
||||
const _adventures = await getAdventures('');
|
||||
if (_adventures) {
|
||||
adventures = _adventures;
|
||||
}
|
||||
});
|
||||
|
||||
let adventures = $state([]);
|
||||
</script>
|
||||
|
||||
<div class="app-page">
|
||||
<header class="page-header">
|
||||
<h1 class="page-title">Adventures</h1>
|
||||
|
||||
<a href={resolve('/app/adventures/new')} class="btn">Create</a>
|
||||
</header>
|
||||
|
||||
{#each adventures as adventure (adventure.id)}
|
||||
<div class="adventure-card">
|
||||
<h2>{adventure.title}</h2>
|
||||
<p>{adventure.description}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
12
frontend/src/routes/app/adventures/getAdventures.remote.ts
Normal file
12
frontend/src/routes/app/adventures/getAdventures.remote.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { getRequestEvent, query } from '$app/server';
|
||||
import { listAdventuresApiAdventuresGet } from '@client';
|
||||
|
||||
export const getAdventures = query('unchecked', async () => {
|
||||
const request = getRequestEvent();
|
||||
const { data } = await listAdventuresApiAdventuresGet({
|
||||
headers: {
|
||||
Authorization: `Bearer ${request.locals.authToken}`
|
||||
}
|
||||
});
|
||||
return data;
|
||||
});
|
||||
167
frontend/src/routes/app/adventures/new/+page.server.ts
Normal file
167
frontend/src/routes/app/adventures/new/+page.server.ts
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { error, type Action, type Actions } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { createAdventureApiAdventuresPost } from '@client';
|
||||
import * as v from 'valibot';
|
||||
import { shuffleArray } from '$lib';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {
|
||||
lengths: ['100-200 Words', '200-350 Words', '350-500 Words', '500-750 Words'],
|
||||
competencies: ['A1', 'A2', 'B1', 'B2', 'C1'],
|
||||
languages: [
|
||||
{ code: 'fr', name: 'French' },
|
||||
{ code: 'de', name: 'German' },
|
||||
{ code: 'it', name: 'Italian' },
|
||||
{ code: 'es', name: 'Spanish' },
|
||||
{ code: 'pt', name: 'Portuguese' }
|
||||
],
|
||||
genres: shuffleArray([
|
||||
'Crime Fiction',
|
||||
'Crime noir',
|
||||
'Who-dun-it mystery',
|
||||
'Paranormal',
|
||||
'Horror',
|
||||
'Psychological thriller',
|
||||
'Romance',
|
||||
'Family',
|
||||
'Fantasy',
|
||||
'Science Fiction'
|
||||
]),
|
||||
eras: [
|
||||
'Ancient/Classical',
|
||||
'Medieval',
|
||||
'1500-1800',
|
||||
'Renaissance',
|
||||
'19th century',
|
||||
'Early 20th century',
|
||||
'Mid-century',
|
||||
'Contemporary',
|
||||
'Near future',
|
||||
'Far future'
|
||||
],
|
||||
settings: [
|
||||
'Capital city',
|
||||
'Large city (not the capital)',
|
||||
'Urban',
|
||||
'The suburbs',
|
||||
'Rural',
|
||||
'Pastoral',
|
||||
'Small town',
|
||||
'Wilderness',
|
||||
'Space',
|
||||
'Another planet'
|
||||
],
|
||||
vibes: [
|
||||
'Melancholic',
|
||||
'Gothic',
|
||||
'Sun-drenched',
|
||||
'Bleak',
|
||||
'Whimsical',
|
||||
'Eerie',
|
||||
'Cosy',
|
||||
'Tense',
|
||||
'Witty',
|
||||
'Propulsive',
|
||||
'Mentor and student',
|
||||
'Unlikely duo',
|
||||
'Lone wolf',
|
||||
'Queer-norm',
|
||||
'Class tensions',
|
||||
'Chosen family',
|
||||
'Diaspora',
|
||||
'Academia',
|
||||
'Small town',
|
||||
'The sea',
|
||||
'Grand house',
|
||||
'Road trip',
|
||||
'A single night',
|
||||
'Heist',
|
||||
'Mystery box',
|
||||
'Reluctant hero',
|
||||
'Redemption',
|
||||
'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 CreateFormSchema = v.object({
|
||||
vibes: v.array(v.string()),
|
||||
genre: v.string(),
|
||||
setting: v.string(),
|
||||
competency: v.pipe(v.string(), v.picklist(['A1', 'A2', 'B1', 'B2', 'C1'])),
|
||||
language: v.pipe(v.string(), v.picklist(['fr', 'it', 'de', 'it', 'es'])),
|
||||
length: v.string(),
|
||||
protagonist_gender: v.string(),
|
||||
protagonist_age: v.string()
|
||||
});
|
||||
|
||||
export const actions = {
|
||||
default: async ({ locals, request }) => {
|
||||
const { authToken } = locals;
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const data = v.safeParse(CreateFormSchema, {
|
||||
vibes: formData.getAll('vibes') as string[],
|
||||
genre: formData.get('genre') as string,
|
||||
setting: formData.get('setting') as string,
|
||||
competency: formData.get('competency') as string,
|
||||
language: formData.get('language') as string,
|
||||
length: formData.get('length') as string,
|
||||
protagonist_gender: formData.get('protagonist_gender') as string,
|
||||
protagonist_age: formData.get('protagonist_age') as string
|
||||
});
|
||||
|
||||
if (data.success == false) {
|
||||
throw error(400, {
|
||||
message: data.issues.map((e) => e.message).join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
competency,
|
||||
language,
|
||||
length,
|
||||
genre,
|
||||
setting,
|
||||
protagonist_gender,
|
||||
protagonist_age,
|
||||
vibes
|
||||
} = data.output;
|
||||
|
||||
const { data: apiData, error: apiError } = await createAdventureApiAdventuresPost({
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`
|
||||
},
|
||||
body: {
|
||||
competencies: [competency],
|
||||
language: language,
|
||||
genres: [genre],
|
||||
setting: [setting],
|
||||
vibes: vibes,
|
||||
protagonist: [protagonist_gender, protagonist_age],
|
||||
source_language: 'en',
|
||||
entry_word_count_range: length,
|
||||
max_entry_count: 6
|
||||
}
|
||||
});
|
||||
|
||||
console.log({ apiData, apiError });
|
||||
}
|
||||
} satisfies Actions;
|
||||
123
frontend/src/routes/app/adventures/new/+page.svelte
Normal file
123
frontend/src/routes/app/adventures/new/+page.svelte
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<script lang="ts">
|
||||
import { shuffleArray } from '$lib';
|
||||
import type { ChangeEventHandler } from 'svelte/elements';
|
||||
|
||||
import type { PageServerData } from './$types';
|
||||
|
||||
const { data }: { data: PageServerData } = $props();
|
||||
|
||||
let theVibes = $state(shuffleArray(data.vibes).slice(0, 7));
|
||||
|
||||
const handleVibeSelected: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
const { checked, value } = e.currentTarget;
|
||||
|
||||
if (checked) {
|
||||
selectedVibes = [...selectedVibes, value as string];
|
||||
} else {
|
||||
selectedVibes = selectedVibes.filter((v) => v !== value);
|
||||
}
|
||||
};
|
||||
let selectedVibes = $state([]);
|
||||
let vibeShuffleCount = $state(0);
|
||||
let shuffleTheVibes = () => (theVibes = shuffleArray(data.vibes).slice(0, 5));
|
||||
</script>
|
||||
|
||||
<section class="app-page">
|
||||
<div class="page-header">
|
||||
<div class="page-title">Create new Adventure</div>
|
||||
</div>
|
||||
|
||||
<form class="form" method="POST">
|
||||
<div class="field">
|
||||
<label for="genre" class="label">Genre</label>
|
||||
<select name="genre" id="genre">
|
||||
{#each data.genres as genre (genre)}
|
||||
<option value={genre}>{genre}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="setting" class="label">Setting</label>
|
||||
<select name="setting" id="setting">
|
||||
{#each ['Urban', 'Rural', 'Coastal', 'Mountain', 'Forest', 'Desert'] as setting (setting)}
|
||||
<option value={setting}>{setting}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<fieldset class="field-fieldset">
|
||||
<legend>Entry length</legend>
|
||||
{#each data.lengths as length (length)}
|
||||
{@const inputId = `length-${length}`}
|
||||
<div class="radio-option">
|
||||
<input type="radio" name="length" value={length} id={inputId} />
|
||||
<label for={inputId}>{length}</label>
|
||||
</div>
|
||||
{/each}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<fieldset class="field-fieldset">
|
||||
<legend>Vibes (pick two)</legend>
|
||||
{#each theVibes as vibe (vibe)}
|
||||
{@const theId = `vibe-${vibe}`}
|
||||
<label for={theId} class="label">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={selectedVibes.length >= 2 && !selectedVibes.includes(vibe)}
|
||||
id={theId}
|
||||
value={vibe}
|
||||
onchange={handleVibeSelected}
|
||||
/>
|
||||
{vibe}
|
||||
</label>
|
||||
{/each}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="language" class="label">Language</label>
|
||||
<select name="language" id="language">
|
||||
{#each data.languages as language (language.code)}
|
||||
<option value={language.code}>{language.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="competency" class="label">Competency</label>
|
||||
<select name="competency" id="competency">
|
||||
{#each data.competencies as competency (competency)}
|
||||
<option value={competency}>{competency}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<fieldset class="field-fieldset">
|
||||
<legend>Protagonist gender</legend>
|
||||
{#each ['male', 'female', 'non-binary', 'any'] as gender (gender)}
|
||||
{@const inputId = `gender-${gender}`}
|
||||
<div class="radio-option">
|
||||
<input type="radio" name="protagonist_gender" value={gender} id={inputId} />
|
||||
<label for={inputId}>{gender}</label>
|
||||
</div>
|
||||
{/each}
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="protagonist_age" class="label">Protagonist age</label>
|
||||
<select name="protagonist_age" id="protagonist_age">
|
||||
{#each ['Adult', 'Child', 'Teen', 'Young Adult', 'Older Adult', 'Middle-aged', 'Older'] as age (age)}
|
||||
<option value={age}>{age}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<input type="submit" value="Create Adventure" class="submit-button" />
|
||||
</form>
|
||||
</section>
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
}).format(new Date(iso));
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<div class="app-page">
|
||||
<header class="page-header">
|
||||
<p class="form-eyebrow">Reading</p>
|
||||
<h1 class="form-title">Articles</h1>
|
||||
|
|
@ -44,7 +44,9 @@
|
|||
</div>
|
||||
<h2 class="article-title">{article.target_title}</h2>
|
||||
<p class="article-source">{article.source_title}</p>
|
||||
<time class="article-date label-md" datetime={article.published_at}>{fmt(article.published_at)}</time>
|
||||
<time class="article-date label-md" datetime={article.published_at}
|
||||
>{fmt(article.published_at)}</time
|
||||
>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
|
|
@ -58,15 +60,6 @@
|
|||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
max-width: 52rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-8) var(--space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
/* --- Article list --- */
|
||||
|
||||
.article-list {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const config = {
|
|||
remoteFunctions: true
|
||||
},
|
||||
alias: {
|
||||
'@client': 'src/client/client.gen.ts'
|
||||
'@client': 'src/client/sdk.gen'
|
||||
}
|
||||
},
|
||||
compilerOptions: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue