From 293a8ab3f90334a8f598f9e84cb6c465331902cd Mon Sep 17 00:00:00 2001 From: wilson Date: Sun, 17 May 2026 13:36:21 +0100 Subject: [PATCH] fix: Commit various features relating to CYOA --- api/app/domain/services/adventure_service.py | 2 +- ...technical-doc-choose-your-own-adventure.md | 2 - .../routes/app/adventures/[id]/+page.svelte | 1 + .../app/adventures/[id]/LatestEntry.svelte | 397 ++++++++++++++++-- .../app/adventures/[id]/adventureState.ts | 1 + 5 files changed, 368 insertions(+), 35 deletions(-) diff --git a/api/app/domain/services/adventure_service.py b/api/app/domain/services/adventure_service.py index 6fae0a4..e2b38db 100644 --- a/api/app/domain/services/adventure_service.py +++ b/api/app/domain/services/adventure_service.py @@ -251,7 +251,7 @@ class AdventureService: 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 + target_sent["text"], adventure.source_language, paragraph_text ) timing_translations += time.monotonic() - t0 diff --git a/api/docs/technical-doc-choose-your-own-adventure.md b/api/docs/technical-doc-choose-your-own-adventure.md index f4fe369..468ac33 100644 --- a/api/docs/technical-doc-choose-your-own-adventure.md +++ b/api/docs/technical-doc-choose-your-own-adventure.md @@ -146,7 +146,6 @@ def parse_llm_response(text: str) -> tuple[str, list[tuple[str, str]], str]: app/domain/models/adventure.py app/domain/services/adventure_service.py app/routers/api/adventures.py -app/routers/bff/adventures.py app/outbound/postgres/entities/adventure_entities.py app/outbound/postgres/repositories/adventure_repository.py alembic/versions/20260503_0016_add_choose_your_own_adventure.py @@ -158,7 +157,6 @@ Modified files: ``` app/outbound/anthropic/anthropic_client.py (add 2 methods) app/routers/api/main.py (register router) -app/routers/bff/main.py (register router) ``` --- diff --git a/frontend/src/routes/app/adventures/[id]/+page.svelte b/frontend/src/routes/app/adventures/[id]/+page.svelte index 620ea9e..f213b0d 100644 --- a/frontend/src/routes/app/adventures/[id]/+page.svelte +++ b/frontend/src/routes/app/adventures/[id]/+page.svelte @@ -299,6 +299,7 @@ | null | undefined; audioUrl: string | null | undefined; onSelectNextStep: (optionId: string) => Promise; @@ -24,6 +25,7 @@ const { sourceText, translationText, + storyTextLinguisticData, audioUrl, onSelectNextStep, @@ -33,10 +35,222 @@ errorMessage }: Props = $props(); - const sourceParagraphs = $derived.by(() => toParagraphs(sourceText)); - const translationParagraphs = $derived.by(() => toParagraphs(translationText)); + type LinguisticToken = { + text: string; + lemma: string | null; + pos: string | null; + }; - let lastClickedParagraphIndex: number | null = $state(null); + type TextSegment = { + kind: 'text'; + text: string; + }; + + type WordSegment = { + kind: 'word'; + text: string; + lemma: string | null; + pos: string | null; + }; + + type SentenceSegments = { + key: string; + sourceText: string; + targetText: string; + sourceSegments: Array; + targetSegments: Array; + }; + + type LinguisticParagraph = { + key: string; + sourceText: string; + targetText: string; + sentences: SentenceSegments[]; + }; + + function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; + } + + function asString(value: unknown): string | null { + return typeof value === 'string' ? value : null; + } + + function parseTokens(value: unknown): LinguisticToken[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((token): LinguisticToken | null => { + if (!isRecord(token)) { + return null; + } + + const text = asString(token.text); + if (!text) { + return null; + } + + return { + text, + lemma: asString(token.lemma), + pos: asString(token.pos) + }; + }) + .filter((token): token is LinguisticToken => token !== null); + } + + function buildSegments( + text: string, + tokens: LinguisticToken[] + ): Array { + if (tokens.length === 0) { + return text ? [{ kind: 'text', text }] : []; + } + + if (!text) { + return tokens.flatMap((token, index) => [ + { kind: 'word', text: token.text, lemma: token.lemma, pos: token.pos } as WordSegment, + ...(index < tokens.length - 1 ? ([{ kind: 'text', text: ' ' }] as TextSegment[]) : []) + ]); + } + + const segments: Array = []; + let cursor = 0; + + for (const token of tokens) { + const tokenIndex = text.indexOf(token.text, cursor); + + if (tokenIndex === -1) { + continue; + } + + if (tokenIndex > cursor) { + segments.push({ + kind: 'text', + text: text.slice(cursor, tokenIndex) + }); + } + + segments.push({ + kind: 'word', + text: token.text, + lemma: token.lemma, + pos: token.pos + }); + + cursor = tokenIndex + token.text.length; + } + + if (cursor < text.length) { + segments.push({ + kind: 'text', + text: text.slice(cursor) + }); + } + + return segments.length > 0 ? segments : [{ kind: 'text', text }]; + } + + function parseLinguisticParagraphs( + value: Record | null | undefined + ): LinguisticParagraph[] { + if (!value) { + return []; + } + + const paragraphs = value.paragraphs; + if (!Array.isArray(paragraphs)) { + return []; + } + + return paragraphs + .map((paragraphValue, paragraphIndex): LinguisticParagraph | null => { + if (!isRecord(paragraphValue)) { + return null; + } + + const sentencesRaw = paragraphValue.sentences; + const sentenceValues = Array.isArray(sentencesRaw) ? sentencesRaw : []; + + const sentences = sentenceValues + .map((sentenceValue, sentenceIndex): SentenceSegments | null => { + if (!isRecord(sentenceValue)) { + return null; + } + + const sourceSentence = asString(sentenceValue.source_text) ?? ''; + const targetSentence = asString(sentenceValue.target_text) ?? ''; + const sourceTokens = parseTokens(sentenceValue.source_tokens); + const targetTokens = parseTokens(sentenceValue.target_tokens); + + return { + key: `${paragraphIndex}-${sentenceIndex}`, + sourceText: sourceSentence, + targetText: targetSentence, + sourceSegments: buildSegments(sourceSentence, sourceTokens), + targetSegments: buildSegments(targetSentence, targetTokens) + }; + }) + .filter((sentence): sentence is SentenceSegments => sentence !== null); + + const sourceText = + asString(paragraphValue.source_text) ?? + sentences + .map((sentence) => sentence.sourceText) + .filter(Boolean) + .join(' '); + const targetText = + asString(paragraphValue.target_text) ?? + sentences + .map((sentence) => sentence.targetText) + .filter(Boolean) + .join(' '); + + if (sentences.length === 0 && (sourceText || targetText)) { + sentences.push({ + key: `${paragraphIndex}-0`, + sourceText, + targetText, + sourceSegments: sourceText ? [{ kind: 'text', text: sourceText }] : [], + targetSegments: targetText ? [{ kind: 'text', text: targetText }] : [] + }); + } + + if (!sourceText && !targetText && sentences.length === 0) { + return null; + } + + return { + key: `p-${paragraphIndex}`, + sourceText, + targetText, + sentences + }; + }) + .filter((paragraph): paragraph is LinguisticParagraph => paragraph !== null); + } + + function isWordLike(text: string): boolean { + return /[\p{L}\p{N}]/u.test(text); + } + + const linguisticParagraphs = $derived.by(() => + parseLinguisticParagraphs(storyTextLinguisticData) + ); + const sourceParagraphs = $derived.by(() => + linguisticParagraphs.length > 0 + ? linguisticParagraphs.map((paragraph) => paragraph.targetText).filter(Boolean) + : toParagraphs(sourceText) + ); + const translationParagraphs = $derived.by(() => + linguisticParagraphs.length > 0 + ? linguisticParagraphs.map((paragraph) => paragraph.sourceText).filter(Boolean) + : toParagraphs(translationText) + ); + + let selectedWord: { sentenceKey: string; text: string } | null = $state(null); let sourcePane = $state(); let translationPane = $state(); let suppressSourceScroll = $state(false); @@ -101,8 +315,9 @@ }, 20000); } - function handleParagraphClicked(paragraphIndex: number) { - lastClickedParagraphIndex = paragraphIndex; + function handleWordClicked(sentenceKey: string, text: string) { + selectedWord = { sentenceKey, text }; + showTranslation(); } async function handleNextStepSelect(optionId: string) { @@ -146,18 +361,45 @@
{#if sourceParagraphs.length > 0} - {#each sourceParagraphs as paragraph, index (index)} - - {/each} + {#if linguisticParagraphs.length > 0} + {#each linguisticParagraphs as paragraph (paragraph.key)} +

+ {#each paragraph.sentences as sentence (sentence.key)} + + {#each sentence.targetSegments as segment, segmentIndex (`${sentence.key}-target-${segmentIndex}`)} + {#if segment.kind === 'word' && isWordLike(segment.text)} + + {:else} + {segment.text} + {/if} + {/each} + + {/each} +

+ {/each} + {:else} + {#each sourceParagraphs as paragraph, index (index)} +

+ {paragraph} +

+ {/each} + {/if} {:else}

{statusMessage || 'Writing your next entry...'}

@@ -177,6 +419,12 @@ + {#if selectedWord} +

+ Selected word: {selectedWord.text} +

+ {/if} + {#if translationVisible}
{#if translationParagraphs.length > 0} - {#each translationParagraphs as paragraph, index (index)} - - {/each} + {#if linguisticParagraphs.length > 0} + {#each linguisticParagraphs as paragraph (paragraph.key)} +

+ {#each paragraph.sentences as sentence (sentence.key)} + + {#each sentence.sourceSegments as segment, segmentIndex (`${sentence.key}-source-${segmentIndex}`)} + {#if segment.kind === 'word'} + + {segment.text} + + {:else} + {segment.text} + {/if} + {/each} + + {/each} +

+ {/each} + {:else} + {#each translationParagraphs as paragraph, index (index)} +

+ {paragraph} +

+ {/each} + {/if} {:else}

@@ -511,6 +783,18 @@ color: color-mix(in srgb, var(--color-on-surface) 72%, transparent); } + .translation-selected-word { + margin: 0; + padding: 0 var(--latest-entry-pane-padding) var(--space-2); + font-size: var(--text-label-md); + color: color-mix(in srgb, var(--color-on-surface) 84%, transparent); + } + + .translation-selected-word strong { + font-weight: var(--weight-semibold); + color: var(--color-primary); + } + .latest-entry__pane-body::-webkit-scrollbar { width: 0.75rem; } @@ -542,9 +826,58 @@ transition: background-color var(--duration-fast) var(--ease-standard); } - .paragraph.active { - background-color: color-mix(in srgb, var(--color-primary-container) 56%, transparent); - border-radius: var(--radius-md); + .paragraph--text { + margin: 0; + padding: 0; + border: none; + background: transparent; + cursor: default; + white-space: pre-wrap; + } + + .sentence-chunk { + display: inline; + border-radius: var(--radius-sm); + transition: background-color var(--duration-fast) var(--ease-standard); + + &::after { + content: ''; + display: inline-block; + width: 0.85ch; + } + } + + .sentence-chunk.active-sentence { + background-color: color-mix(in srgb, var(--color-primary-container) 32%, transparent); + } + + .word-token { + display: inline; + padding: 0; + margin: 0; + border: none; + background: transparent; + color: inherit; + font: inherit; + line-height: inherit; + cursor: pointer; + } + + .word-token:hover { + text-decoration: underline; + } + + .word-token.active { + color: var(--color-primary); + text-decoration: underline; + } + + .word-token--passive { + cursor: default; + } + + .word-token--passive:hover { + text-decoration: none; } .paragraph:focus-visible { diff --git a/frontend/src/routes/app/adventures/[id]/adventureState.ts b/frontend/src/routes/app/adventures/[id]/adventureState.ts index 2ab47fa..15d49f9 100644 --- a/frontend/src/routes/app/adventures/[id]/adventureState.ts +++ b/frontend/src/routes/app/adventures/[id]/adventureState.ts @@ -4,6 +4,7 @@ export type AdventureEntry = { id: string; story_text: string | null; translation: string | null; + story_text_linguistic_data: Record | null; audio_url: string | null; generated_from_choice_id: string | null; possible_choices: { id: string; text: string }[] | null;