fix: Commit various features relating to CYOA

This commit is contained in:
wilson 2026-05-17 13:36:21 +01:00
parent 941396fc60
commit 293a8ab3f9
5 changed files with 368 additions and 35 deletions

View file

@ -251,7 +251,7 @@ class AdventureService:
for sent_idx, target_sent in enumerate(target_nlp["sentences"]): for sent_idx, target_sent in enumerate(target_nlp["sentences"]):
t0 = time.monotonic() t0 = time.monotonic()
translated_sentence = await self.deepl_client.translate( 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 timing_translations += time.monotonic() - t0

View file

@ -146,7 +146,6 @@ def parse_llm_response(text: str) -> tuple[str, list[tuple[str, str]], str]:
app/domain/models/adventure.py app/domain/models/adventure.py
app/domain/services/adventure_service.py app/domain/services/adventure_service.py
app/routers/api/adventures.py app/routers/api/adventures.py
app/routers/bff/adventures.py
app/outbound/postgres/entities/adventure_entities.py app/outbound/postgres/entities/adventure_entities.py
app/outbound/postgres/repositories/adventure_repository.py app/outbound/postgres/repositories/adventure_repository.py
alembic/versions/20260503_0016_add_choose_your_own_adventure.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/outbound/anthropic/anthropic_client.py (add 2 methods)
app/routers/api/main.py (register router) app/routers/api/main.py (register router)
app/routers/bff/main.py (register router)
``` ```
--- ---

View file

@ -299,6 +299,7 @@
<LatestEntry <LatestEntry
sourceText={latestEntry?.story_text} sourceText={latestEntry?.story_text}
translationText={latestEntry?.translation} translationText={latestEntry?.translation}
storyTextLinguisticData={latestEntry?.story_text_linguistic_data}
audioUrl={latestEntry?.audio_url} audioUrl={latestEntry?.audio_url}
onSelectNextStep={handleNextStepSelect} onSelectNextStep={handleNextStepSelect}
isWaitingForGeneration={$adventureState.ui.isWaitingForGeneration} isWaitingForGeneration={$adventureState.ui.isWaitingForGeneration}

View file

@ -6,6 +6,7 @@
type Props = { type Props = {
sourceText: string | null | undefined; sourceText: string | null | undefined;
translationText: string | null | undefined; translationText: string | null | undefined;
storyTextLinguisticData: Record<string, unknown> | null | undefined;
audioUrl: string | null | undefined; audioUrl: string | null | undefined;
onSelectNextStep: (optionId: string) => Promise<void>; onSelectNextStep: (optionId: string) => Promise<void>;
@ -24,6 +25,7 @@
const { const {
sourceText, sourceText,
translationText, translationText,
storyTextLinguisticData,
audioUrl, audioUrl,
onSelectNextStep, onSelectNextStep,
@ -33,10 +35,222 @@
errorMessage errorMessage
}: Props = $props(); }: Props = $props();
const sourceParagraphs = $derived.by(() => toParagraphs(sourceText)); type LinguisticToken = {
const translationParagraphs = $derived.by(() => toParagraphs(translationText)); 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<TextSegment | WordSegment>;
targetSegments: Array<TextSegment | WordSegment>;
};
type LinguisticParagraph = {
key: string;
sourceText: string;
targetText: string;
sentences: SentenceSegments[];
};
function isRecord(value: unknown): value is Record<string, unknown> {
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<TextSegment | WordSegment> {
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<TextSegment | WordSegment> = [];
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<string, unknown> | 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<HTMLDivElement | undefined>(); let sourcePane = $state<HTMLDivElement | undefined>();
let translationPane = $state<HTMLDivElement | undefined>(); let translationPane = $state<HTMLDivElement | undefined>();
let suppressSourceScroll = $state(false); let suppressSourceScroll = $state(false);
@ -101,8 +315,9 @@
}, 20000); }, 20000);
} }
function handleParagraphClicked(paragraphIndex: number) { function handleWordClicked(sentenceKey: string, text: string) {
lastClickedParagraphIndex = paragraphIndex; selectedWord = { sentenceKey, text };
showTranslation();
} }
async function handleNextStepSelect(optionId: string) { async function handleNextStepSelect(optionId: string) {
@ -146,18 +361,45 @@
<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} {#if sourceParagraphs.length > 0}
{#each sourceParagraphs as paragraph, index (index)} {#if linguisticParagraphs.length > 0}
<button {#each linguisticParagraphs as paragraph (paragraph.key)}
type="button" <p class="paragraph paragraph--text" data-language="source">
class="paragraph" {#each paragraph.sentences as sentence (sentence.key)}
class:active={lastClickedParagraphIndex === index} <span
data-paragraph-index={index} class="sentence-chunk"
data-language="source" class:active-sentence={selectedWord?.sentenceKey === sentence.key}
onclick={() => handleParagraphClicked(index)} >
> {#each sentence.targetSegments as segment, segmentIndex (`${sentence.key}-target-${segmentIndex}`)}
{paragraph} {#if segment.kind === 'word' && isWordLike(segment.text)}
</button> <button
{/each} type="button"
class="word-token"
class:active={selectedWord?.sentenceKey === sentence.key &&
selectedWord?.text === segment.text}
title={segment.lemma ? `Lemma: ${segment.lemma}` : undefined}
onclick={() => handleWordClicked(sentence.key, segment.text)}
>
{segment.text}
</button>
{:else}
<span>{segment.text}</span>
{/if}
{/each}
</span>
{/each}
</p>
{/each}
{:else}
{#each sourceParagraphs as paragraph, index (index)}
<p
class="paragraph paragraph--text"
data-paragraph-index={index}
data-language="source"
>
{paragraph}
</p>
{/each}
{/if}
{:else} {:else}
<div class="loading-block" role="status" aria-live="polite"> <div class="loading-block" role="status" aria-live="polite">
<p class="loading-block__label">{statusMessage || 'Writing your next entry...'}</p> <p class="loading-block__label">{statusMessage || 'Writing your next entry...'}</p>
@ -177,6 +419,12 @@
</button> </button>
</header> </header>
{#if selectedWord}
<p class="translation-selected-word" role="status" aria-live="polite">
Selected word: <strong>{selectedWord.text}</strong>
</p>
{/if}
{#if translationVisible} {#if translationVisible}
<div <div
class="latest-entry__pane-body" class="latest-entry__pane-body"
@ -184,18 +432,42 @@
onscroll={handleTranslationScroll} onscroll={handleTranslationScroll}
> >
{#if translationParagraphs.length > 0} {#if translationParagraphs.length > 0}
{#each translationParagraphs as paragraph, index (index)} {#if linguisticParagraphs.length > 0}
<button {#each linguisticParagraphs as paragraph (paragraph.key)}
type="button" <p class="paragraph paragraph--text" data-language="translation">
class="paragraph" {#each paragraph.sentences as sentence (sentence.key)}
class:active={lastClickedParagraphIndex === index} <span
data-paragraph-index={index} class="sentence-chunk"
data-language="translation" class:active-sentence={selectedWord?.sentenceKey === sentence.key}
onclick={() => handleParagraphClicked(index)} >
> {#each sentence.sourceSegments as segment, segmentIndex (`${sentence.key}-source-${segmentIndex}`)}
{paragraph} {#if segment.kind === 'word'}
</button> <span
{/each} class="word-token word-token--passive"
class:active={selectedWord?.sentenceKey === sentence.key &&
selectedWord?.text === segment.text}
>
{segment.text}
</span>
{:else}
<span>{segment.text}</span>
{/if}
{/each}
</span>
{/each}
</p>
{/each}
{:else}
{#each translationParagraphs as paragraph, index (index)}
<p
class="paragraph paragraph--text"
data-paragraph-index={index}
data-language="translation"
>
{paragraph}
</p>
{/each}
{/if}
{:else} {:else}
<div class="loading-block" role="status" aria-live="polite"> <div class="loading-block" role="status" aria-live="polite">
<p class="loading-block__label"> <p class="loading-block__label">
@ -511,6 +783,18 @@
color: color-mix(in srgb, var(--color-on-surface) 72%, transparent); 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 { .latest-entry__pane-body::-webkit-scrollbar {
width: 0.75rem; width: 0.75rem;
} }
@ -542,9 +826,58 @@
transition: background-color var(--duration-fast) var(--ease-standard); transition: background-color var(--duration-fast) var(--ease-standard);
} }
.paragraph.active { .paragraph--text {
background-color: color-mix(in srgb, var(--color-primary-container) 56%, transparent); margin: 0;
border-radius: var(--radius-md); 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 { .paragraph:focus-visible {

View file

@ -4,6 +4,7 @@ export type AdventureEntry = {
id: string; id: string;
story_text: string | null; story_text: string | null;
translation: string | null; translation: string | null;
story_text_linguistic_data: Record<string, unknown> | null;
audio_url: string | null; audio_url: string | null;
generated_from_choice_id: string | null; generated_from_choice_id: string | null;
possible_choices: { id: string; text: string }[] | null; possible_choices: { id: string; text: string }[] | null;