fix: Commit various features relating to CYOA
This commit is contained in:
parent
941396fc60
commit
293a8ab3f9
5 changed files with 368 additions and 35 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -299,6 +299,7 @@
|
|||
<LatestEntry
|
||||
sourceText={latestEntry?.story_text}
|
||||
translationText={latestEntry?.translation}
|
||||
storyTextLinguisticData={latestEntry?.story_text_linguistic_data}
|
||||
audioUrl={latestEntry?.audio_url}
|
||||
onSelectNextStep={handleNextStepSelect}
|
||||
isWaitingForGeneration={$adventureState.ui.isWaitingForGeneration}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
type Props = {
|
||||
sourceText: string | null | undefined;
|
||||
translationText: string | null | undefined;
|
||||
storyTextLinguisticData: Record<string, unknown> | null | undefined;
|
||||
audioUrl: string | null | undefined;
|
||||
|
||||
onSelectNextStep: (optionId: string) => Promise<void>;
|
||||
|
|
@ -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<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 translationPane = $state<HTMLDivElement | undefined>();
|
||||
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 @@
|
|||
<div class="pane source-pane">
|
||||
<div class="latest-entry__pane-body" bind:this={sourcePane} onscroll={handleSourceScroll}>
|
||||
{#if sourceParagraphs.length > 0}
|
||||
{#each sourceParagraphs as paragraph, index (index)}
|
||||
{#if linguisticParagraphs.length > 0}
|
||||
{#each linguisticParagraphs as paragraph (paragraph.key)}
|
||||
<p class="paragraph paragraph--text" data-language="source">
|
||||
{#each paragraph.sentences as sentence (sentence.key)}
|
||||
<span
|
||||
class="sentence-chunk"
|
||||
class:active-sentence={selectedWord?.sentenceKey === sentence.key}
|
||||
>
|
||||
{#each sentence.targetSegments as segment, segmentIndex (`${sentence.key}-target-${segmentIndex}`)}
|
||||
{#if segment.kind === 'word' && isWordLike(segment.text)}
|
||||
<button
|
||||
type="button"
|
||||
class="paragraph"
|
||||
class:active={lastClickedParagraphIndex === index}
|
||||
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"
|
||||
onclick={() => handleParagraphClicked(index)}
|
||||
>
|
||||
{paragraph}
|
||||
</button>
|
||||
</p>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="loading-block" role="status" aria-live="polite">
|
||||
<p class="loading-block__label">{statusMessage || 'Writing your next entry...'}</p>
|
||||
|
|
@ -177,6 +419,12 @@
|
|||
</button>
|
||||
</header>
|
||||
|
||||
{#if selectedWord}
|
||||
<p class="translation-selected-word" role="status" aria-live="polite">
|
||||
Selected word: <strong>{selectedWord.text}</strong>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if translationVisible}
|
||||
<div
|
||||
class="latest-entry__pane-body"
|
||||
|
|
@ -184,18 +432,42 @@
|
|||
onscroll={handleTranslationScroll}
|
||||
>
|
||||
{#if translationParagraphs.length > 0}
|
||||
{#if linguisticParagraphs.length > 0}
|
||||
{#each linguisticParagraphs as paragraph (paragraph.key)}
|
||||
<p class="paragraph paragraph--text" data-language="translation">
|
||||
{#each paragraph.sentences as sentence (sentence.key)}
|
||||
<span
|
||||
class="sentence-chunk"
|
||||
class:active-sentence={selectedWord?.sentenceKey === sentence.key}
|
||||
>
|
||||
{#each sentence.sourceSegments as segment, segmentIndex (`${sentence.key}-source-${segmentIndex}`)}
|
||||
{#if segment.kind === 'word'}
|
||||
<span
|
||||
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)}
|
||||
<button
|
||||
type="button"
|
||||
class="paragraph"
|
||||
class:active={lastClickedParagraphIndex === index}
|
||||
<p
|
||||
class="paragraph paragraph--text"
|
||||
data-paragraph-index={index}
|
||||
data-language="translation"
|
||||
onclick={() => handleParagraphClicked(index)}
|
||||
>
|
||||
{paragraph}
|
||||
</button>
|
||||
</p>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="loading-block" role="status" aria-live="polite">
|
||||
<p class="loading-block__label">
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export type AdventureEntry = {
|
|||
id: string;
|
||||
story_text: string | null;
|
||||
translation: string | null;
|
||||
story_text_linguistic_data: Record<string, unknown> | null;
|
||||
audio_url: string | null;
|
||||
generated_from_choice_id: string | null;
|
||||
possible_choices: { id: string; text: string }[] | null;
|
||||
|
|
|
|||
Loading…
Reference in a new issue