diff --git a/apps/web/src/lib/practice.test.ts b/apps/web/src/lib/practice.test.ts index c655732..972f14a 100644 --- a/apps/web/src/lib/practice.test.ts +++ b/apps/web/src/lib/practice.test.ts @@ -94,6 +94,23 @@ describe('buildPracticeSystemPrompt', () => { expect(prompt).toContain('What did you do last weekend?'); }); + it('includes the previous sample and regeneration rule when regenerating a story', () => { + const prompt = buildPracticeSystemPrompt({ + ...baseOptions, + exerciseType: 'story', + count: 5, + storyTopic: 'Where would you go on a holiday?', + previousStory: { + title: '방학 때의 계획', + sentences: [{ hangul: '저는 부산에 가고 싶어요.', english: 'I want to go to Busan.' }], + }, + }); + expect(prompt).toContain('Previous sample'); + expect(prompt).toContain('방학 때의 계획'); + expect(prompt).toContain('저는 부산에 가고 싶어요.'); + expect(prompt).toContain('This is a regeneration'); + }); + it('emits only vocabulary_candidates as the primary payload for the vocabulary type', () => { const prompt = buildPracticeSystemPrompt({ ...baseOptions, exerciseType: 'vocabulary_candidates', count: 8 }); expect(prompt).toContain('Exactly 8 vocabulary item(s) in "vocabulary_candidates"'); diff --git a/apps/web/src/lib/practice.ts b/apps/web/src/lib/practice.ts index c39ae6d..18fddcb 100644 --- a/apps/web/src/lib/practice.ts +++ b/apps/web/src/lib/practice.ts @@ -24,6 +24,8 @@ export interface PracticeGenerateOptions { grammar: RetrievedGrammar[]; /** Required by the UI when exerciseType is story; included in the system prompt. */ storyTopic?: string; + /** Prior sample to avoid repeating when regenerating a story. */ + previousStory?: { title?: string; sentences: Array<{ hangul: string; english: string }> }; } interface ResponseSpec { @@ -109,18 +111,25 @@ function formatGrammarList(grammar: RetrievedGrammar[]): string { } export function buildPracticeSystemPrompt(options: PracticeGenerateOptions): string { - const { level, themeText, exerciseType, hangul, grammar, storyTopic } = options; + const { level, themeText, exerciseType, hangul, grammar, storyTopic, previousStory } = options; const count = Math.max(1, Math.floor(options.count)); const spec = buildResponseSpec(exerciseType, count); const storyTopicLine = exerciseType === 'story' && storyTopic?.trim() ? `\n\nStory prompt (personal question to answer in first person):\n${storyTopic.trim()}` : ''; + const previousStoryBlock = + exerciseType === 'story' && previousStory?.sentences?.length + ? `\n\nPrevious sample (do NOT reuse — write a clearly different story with a new title and different events/details):\n${ + previousStory.title?.trim() ? `Title: ${previousStory.title.trim()}\n` : '' + }${previousStory.sentences.map((sentence) => sentence.hangul).join(' ')}` + : ''; + return `You are a Korean language tutor creating a beginner practice session. Learner level: ${level.label} ${level.guidance}${level.grammarSummary ? `\n\n${level.grammarSummary}` : ''} -Theme: ${themeText}${storyTopicLine} +Theme: ${themeText}${storyTopicLine}${previousStoryBlock} Prefer this vocabulary when it fits the theme (hangul only; do not invent unrelated words): ${formatHangulList(hangul)} @@ -134,18 +143,24 @@ ${spec.shape} Requirements: ${spec.requirements.map((requirement) => `- ${requirement}`).join('\n')} - Keep hangul natural and beginner-friendly. -- Do not include romanization fields anywhere in the JSON.`; +- Do not include romanization fields anywhere in the JSON.${ + previousStoryBlock ? '\n- This is a regeneration: invent a fresh narrative; do not copy or lightly paraphrase the previous sample.' : '' + }`; } export async function generatePracticeSession(options: PracticeGenerateOptions): Promise { const count = Math.max(1, Math.floor(options.count)); + const regenerating = Boolean(options.previousStory?.sentences?.length); const content = await complete({ messages: [ { role: 'system', content: buildPracticeSystemPrompt(options) }, - { role: 'user', content: "Generate today's practice session JSON." }, + { + role: 'user', + content: regenerating ? 'Generate a different practice session JSON. Do not reuse the previous story.' : "Generate today's practice session JSON.", + }, ], jsonMode: true, - temperature: 0.6, + temperature: regenerating ? 0.9 : 0.6, timeoutMs: PRACTICE_TIMEOUT_MS, maxTokens: estimateMaxTokens(count), // Reasoning models (gemma4, etc.) otherwise fill max_tokens with a reasoning diff --git a/apps/web/src/lib/styles/apps.css b/apps/web/src/lib/styles/apps.css index 0b10bf1..eb902aa 100644 --- a/apps/web/src/lib/styles/apps.css +++ b/apps/web/src/lib/styles/apps.css @@ -168,8 +168,7 @@ width: 100%; } -.practice-app:has(.practice-list--interactive), -.practice-app:has(.practice-story-panels) { +.practice-app:has(.practice-list--interactive) { max-width: min(64rem, 100%); } @@ -187,6 +186,10 @@ border-top: 1px solid var(--pico-muted-border-color); } +.practice-toolbar--end { + justify-content: flex-end; +} + .practice-btn-sm { font-size: 0.75rem; line-height: 1.2; @@ -455,21 +458,56 @@ font-size: var(--kr-font-body); } -.practice-story-panels { - display: grid; - gap: 1rem; +.practice-story-panel h3 { + margin: 0 0 0.75rem; + font-size: var(--kr-font-body); } -@media (min-width: 52rem) { - .practice-story-panels { - grid-template-columns: 1fr 1fr; - align-items: start; - } +.practice-story-tabs { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin: 0 0 1rem; + border-bottom: 1px solid var(--pico-muted-border-color); } -.practice-story-panel h3 { - margin: 0 0 0.75rem; - font-size: var(--kr-font-body); +/* Override Pico button chrome — tabs are text links, not filled buttons. */ +.practice-story-tabs button.practice-story-tab, +.practice-story-tabs button.practice-story-tab:is(:hover, :focus, :focus-visible, :active) { + margin: 0; + padding: 0.55rem 0.9rem; + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + background: transparent; + background-color: transparent; + box-shadow: none; + outline: none; + color: var(--kr-text-primary, var(--pico-color)); + opacity: 0.55; + font-size: var(--kr-font-body-sm); + font-weight: 600; + line-height: 1.2; + height: auto; + min-width: 0; + cursor: pointer; +} + +.practice-story-tabs button.practice-story-tab:is(:hover, :focus, :focus-visible) { + opacity: 0.85; + border-bottom-color: color-mix(in srgb, var(--pico-muted-border-color) 70%, var(--pico-primary-border)); +} + +.practice-story-tabs button.practice-story-tab[aria-selected='true'], +.practice-story-tabs button.practice-story-tab[aria-selected='true']:is(:hover, :focus, :focus-visible, :active) { + color: var(--kr-text-primary, var(--pico-color)); + opacity: 1; + border-bottom-color: var(--pico-primary-border); +} + +.practice-story-tabpanel { + display: grid; + gap: 0.75rem; } .practice-story-panel__header { @@ -478,21 +516,27 @@ align-items: center; justify-content: space-between; gap: 0.5rem; - margin-bottom: 0.75rem; + margin-bottom: 0.25rem; +} + +.practice-story-panel__header--actions { + justify-content: flex-end; } -.practice-story-panel__header h3 { +.practice-story-panel__header h3, +.practice-story-sample-heading { margin: 0; + font-size: var(--kr-font-body); } .practice-story-hint { - margin: -0.25rem 0 0.75rem; + margin: 0; color: var(--pico-muted-color); font-size: var(--kr-font-body-sm); } .practice-story-model-title { - margin: 0 0 0.5rem; + margin: 0; font-size: var(--kr-font-body-sm); color: var(--pico-muted-color); font-weight: 600; @@ -505,7 +549,7 @@ } .practice-story-paragraph--translation { - margin-top: 0.85rem; + margin-top: 0.15rem; padding-top: 0.85rem; border-top: 1px solid var(--pico-muted-border-color); color: var(--pico-muted-color); @@ -513,9 +557,9 @@ } .practice-story-textarea { - margin: 0 0 0.75rem; + margin: 0; width: 100%; - min-height: 10rem; + min-height: 16rem; resize: vertical; } @@ -526,6 +570,14 @@ color: var(--pico-color); } +.practice-story-disclaimer { + margin: 0; + color: var(--pico-muted-color); + font-size: var(--kr-font-caption); + font-style: italic; + text-align: center; +} + .chat-log { display: grid; align-content: start; diff --git a/apps/web/src/routes/education/practice/+page.svelte b/apps/web/src/routes/education/practice/+page.svelte index d28e49a..4347040 100644 --- a/apps/web/src/routes/education/practice/+page.svelte +++ b/apps/web/src/routes/education/practice/+page.svelte @@ -35,9 +35,11 @@ let storyFeedback = $state(''); let storyEvaluating = $state(false); let showModelTranslation = $state(false); + let storyTab = $state<'yours' | 'sample'>('yours'); const isCustomTheme = $derived(selectedTheme === CUSTOM_THEME_ID); const isStory = $derived(exerciseType === 'story'); + const hasSampleStory = $derived(Boolean(session?.story && resultType === 'story')); const STORY_TOPIC_SEEDS = [ 'What did you do last weekend?', @@ -128,6 +130,24 @@ return; } + const existingStory = session?.story; + const previousStory = + exerciseType === 'story' && existingStory && resultType === 'story' + ? { + title: existingStory.title, + sentences: existingStory.sentences.map((sentence) => ({ + hangul: sentence.hangul, + english: sentence.english, + })), + } + : undefined; + + // Hide translation immediately so regenerate never keeps the old English on screen. + if (exerciseType === 'story') { + showModelTranslation = false; + storyFeedback = ''; + } + loading = true; try { status = 'Embedding theme…'; @@ -139,7 +159,7 @@ includeUnassigned: includeSupplemental, }); - status = exerciseType === 'story' ? 'Generating model story…' : 'Generating session…'; + status = exerciseType === 'story' ? 'Generating sample story…' : 'Generating session…'; session = await generatePracticeSession({ level: { label: level.label, guidance: level.guidance, grammarSummary: level.grammarSummary }, themeText, @@ -148,10 +168,16 @@ hangul: retrieved.hangul, grammar: retrieved.grammar, storyTopic: exerciseType === 'story' ? storyTopic.trim() : undefined, + previousStory, }); resultType = exerciseType; resetItemState(); - status = exerciseType === 'story' ? 'Model story ready.' : 'Session ready.'; + if (exerciseType === 'story') { + storyTab = 'sample'; + status = 'Sample story ready.'; + } else { + status = 'Session ready.'; + } } catch (err) { error = err instanceof Error ? err.message : 'Generation failed'; status = ''; @@ -269,8 +295,8 @@ - + @@ -302,17 +328,13 @@ {/if} -
- -
+ {#if !isStory} +
+ +
+ {/if} {#if status}

{status}

@@ -322,60 +344,107 @@ {/if} {#if isStory} -
-
-

Your story

-

Answer the prompt in first person as a short paragraph.

- -
- -
- {#if storyFeedback} -
-

{storyFeedback}

-
- {/if} +
+
+ +
- {#if session?.story && resultType === 'story'} -
-
-

Model story

-
- {#if session.story.title} -

{session.story.title}

+ {#if storyFeedback} +
+

{storyFeedback}

+
{/if} -

- {session.story.sentences.map((sentence) => sentence.hangul).join(' ')} -

- {#if showModelTranslation} - {@const translation = session.story.sentences - .map((sentence) => sentence.english.trim()) - .filter(Boolean) - .join(' ')} - {#if translation} -

- {translation} -

- {:else} -

No English translation was returned for this story. Try generating again.

+
+ {:else} +
+ {#if loading} +

Generating a new sample story…

+
+ +
+ {:else if hasSampleStory && session?.story} +
+ +
+ {#if session.story.title} +

{session.story.title}

+ {/if} +

+ {session.story.sentences.map((sentence) => sentence.hangul).join(' ')} +

+ {#if showModelTranslation} + {@const translation = session.story.sentences + .map((sentence) => sentence.english.trim()) + .filter(Boolean) + .join(' ')} + {#if translation} +

+ {translation} +

+ {:else} +

No English translation was returned for this story. Try generating again.

+ {/if} {/if} +
+ +
+

AI can make mistakes. Please verify the output.

+ {:else} +

Generate a sample first-person story for this topic to use as a reference.

+
+ +
{/if}
{/if}