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"]):
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue