Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/web/src/lib/practice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"');
Expand Down
25 changes: 20 additions & 5 deletions apps/web/src/lib/practice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)}
Expand All @@ -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<PracticeSessionJson> {
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
Expand Down
92 changes: 72 additions & 20 deletions apps/web/src/lib/styles/apps.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%);
}

Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
Expand All @@ -505,17 +549,17 @@
}

.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);
font-size: var(--kr-font-body-sm);
}

.practice-story-textarea {
margin: 0 0 0.75rem;
margin: 0;
width: 100%;
min-height: 10rem;
min-height: 16rem;
resize: vertical;
}

Expand All @@ -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;
Expand Down
Loading