feat(web): multiple-choice — explanation-Feld, Edit-Bug-Fix, State-Reset

- MultipleChoiceCardForm: optionales `explanation`-Feld (Erklärung wird
  nach Auswahl angezeigt); `field-optional`-Style ergänzt
- MultipleChoiceView: `explanation`-Prop; zeigt Erklärungsbox nach
  Auswahl (grün bei richtig, neutral bei falsch); `{#key card_id}`-Block
  erzwingt Remount bei Kartenwechsel — behebt State-Leak zwischen Karten
- edit/+page.svelte: MC-Edit-Bug behoben — Karten wurden fälschlich mit
  `{front, back}` gespeichert und haben `answer`/`distractor_pool`
  überschrieben; `MultipleChoiceCardForm` importiert und verdrahtet;
  `canSave` und `onSubmit` handhaben MC korrekt; lädt `answer` +
  `distractor_pool` beim Öffnen zurück in `mcOptions`-Array
- new/+page.svelte: `mcExplanation`-State an Form gebunden und beim
  Speichern als `fields.explanation` gesetzt
- study/+page.svelte: `explanation` aus Card-Fields extrahiert und
  an MultipleChoiceView durchgereicht
- scripts/migrate-factfulness-to-mc.ts: einmalige Migration — 13
  Factfulness-Quiz-Karten von `basic` (A/B/C in Freitext) auf
  `multiple-choice` mit strukturierten Feldern konvertiert; Deck auf
  `visibility=public` gesetzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-11 18:24:18 +02:00
parent 41ecec16c3
commit 9839737049
6 changed files with 209 additions and 6 deletions

View file

@ -5,10 +5,12 @@
front = $bindable(), front = $bindable(),
mcOptions = $bindable(), mcOptions = $bindable(),
mcCorrectIdx = $bindable(), mcCorrectIdx = $bindable(),
mcExplanation = $bindable(''),
}: { }: {
front: string; front: string;
mcOptions: string[]; mcOptions: string[];
mcCorrectIdx: number; mcCorrectIdx: number;
mcExplanation: string;
} = $props(); } = $props();
</script> </script>
@ -59,6 +61,17 @@
</div> </div>
</div> </div>
<div class="field">
<span class="field-label">Erklärung <span class="field-optional">(optional)</span></span>
<span class="field-hint">Wird nach der Auswahl angezeigt — erklärt warum die Antwort richtig ist.</span>
<textarea
bind:value={mcExplanation}
rows="3"
placeholder="z.B. Weil … / Hintergrund: …"
class="input"
></textarea>
</div>
<style> <style>
.field { .field {
display: flex; display: flex;
@ -73,6 +86,11 @@
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
.field-optional {
font-weight: 400;
color: hsl(var(--color-muted-foreground));
}
.field-hint { .field-hint {
font-size: 0.75rem; font-size: 0.75rem;
color: hsl(var(--color-muted-foreground)); color: hsl(var(--color-muted-foreground));

View file

@ -7,6 +7,7 @@
promptHtml, promptHtml,
answer, answer,
distractorPool, distractorPool,
explanation,
deckId, deckId,
cardId, cardId,
ongrade, ongrade,
@ -14,6 +15,7 @@
promptHtml: string; promptHtml: string;
answer: string; answer: string;
distractorPool?: string; distractorPool?: string;
explanation?: string;
deckId: string; deckId: string;
cardId: string; cardId: string;
ongrade: (r: Rating) => void; ongrade: (r: Rating) => void;
@ -146,6 +148,13 @@
{/each} {/each}
</div> </div>
{#if selected !== null && explanation}
<div class="explanation" class:explanation-correct={correct} class:explanation-wrong={!correct}>
<span class="explanation-label">{correct ? '✓' : ''}</span>
<p class="explanation-text">{explanation}</p>
</div>
{/if}
{#if selected !== null} {#if selected !== null}
<button class="confirm-btn" onclick={confirm}> <button class="confirm-btn" onclick={confirm}>
{correct ? 'Weiter' : 'Verstanden'} <kbd>Space</kbd> {correct ? 'Weiter' : 'Verstanden'} <kbd>Space</kbd>
@ -295,6 +304,38 @@
.option-correct .option-icon { color: hsl(var(--color-success)); } .option-correct .option-icon { color: hsl(var(--color-success)); }
.option-wrong .option-icon { color: hsl(var(--color-error)); } .option-wrong .option-icon { color: hsl(var(--color-error)); }
.explanation {
display: flex;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-surface));
}
.explanation-correct {
background: hsl(var(--color-success) / 0.08);
border-color: hsl(var(--color-success) / 0.4);
}
.explanation-wrong {
background: hsl(var(--color-muted) / 0.5);
}
.explanation-label {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
padding-top: 0.125rem;
}
.explanation-correct .explanation-label {
color: hsl(var(--color-success));
}
.explanation-text {
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
color: hsl(var(--color-foreground));
}
.confirm-btn { .confirm-btn {
align-self: center; align-self: center;
display: inline-flex; display: inline-flex;

View file

@ -16,6 +16,7 @@
import { toasts } from '$lib/stores/toasts.svelte.ts'; import { toasts } from '$lib/stores/toasts.svelte.ts';
import { t } from '$lib/i18n/index.svelte.ts'; import { t } from '$lib/i18n/index.svelte.ts';
import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte'; import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte';
import MultipleChoiceCardForm from '$lib/components/MultipleChoiceCardForm.svelte';
let card = $state<Card | null>(null); let card = $state<Card | null>(null);
let cardType = $state<CardType>('basic'); let cardType = $state<CardType>('basic');
@ -25,6 +26,9 @@
let extra = $state(''); let extra = $state('');
let imageRef = $state(''); let imageRef = $state('');
let maskRegionsJson = $state('[]'); let maskRegionsJson = $state('[]');
let mcOptions = $state(['', '', '', '']);
let mcCorrectIdx = $state(0);
let mcExplanation = $state('');
let loading = $state(true); let loading = $state(true);
let saving = $state(false); let saving = $state(false);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -56,6 +60,14 @@
} else if (c.type === 'image-occlusion') { } else if (c.type === 'image-occlusion') {
imageRef = fields.image_ref ?? ''; imageRef = fields.image_ref ?? '';
maskRegionsJson = fields.mask_regions ?? '[]'; maskRegionsJson = fields.mask_regions ?? '[]';
} else if (c.type === 'multiple-choice') {
front = fields.front ?? '';
const answer = fields.answer ?? '';
const pool = fields.distractor_pool ?? '';
const distractors = pool.split('\n').map((s) => s.trim()).filter((s) => s);
mcOptions = [answer, ...distractors, '', '', ''].slice(0, 4);
mcCorrectIdx = 0;
mcExplanation = fields.explanation ?? '';
} else { } else {
front = fields.front ?? ''; front = fields.front ?? '';
back = fields.back ?? ''; back = fields.back ?? '';
@ -71,11 +83,11 @@
const canSave = $derived.by(() => { const canSave = $derived.by(() => {
if (saving) return false; if (saving) return false;
if (cardType === 'cloze') { if (cardType === 'cloze') return text.trim().length > 0 && clusterIds.length > 0;
return text.trim().length > 0 && clusterIds.length > 0; if (cardType === 'image-occlusion') return imageRef.length > 0 && maskCount > 0;
} if (cardType === 'multiple-choice') {
if (cardType === 'image-occlusion') { const filledCount = mcOptions.filter((o) => o.trim()).length;
return imageRef.length > 0 && maskCount > 0; return front.trim().length > 0 && mcOptions[mcCorrectIdx].trim().length > 0 && filledCount >= 2;
} }
return front.trim().length > 0 && back.trim().length > 0; return front.trim().length > 0 && back.trim().length > 0;
}); });
@ -92,6 +104,14 @@
: { text: text.trim() }; : { text: text.trim() };
} else if (cardType === 'image-occlusion') { } else if (cardType === 'image-occlusion') {
fields = { image_ref: imageRef, mask_regions: maskRegionsJson }; fields = { image_ref: imageRef, mask_regions: maskRegionsJson };
} else if (cardType === 'multiple-choice') {
const distractors = mcOptions
.filter((_, i) => i !== mcCorrectIdx)
.filter((o) => o.trim())
.join('\n');
fields = { front: front.trim(), answer: mcOptions[mcCorrectIdx].trim() };
if (distractors) fields.distractor_pool = distractors;
if (mcExplanation.trim()) fields.explanation = mcExplanation.trim();
} else { } else {
fields = { front: front.trim(), back: back.trim() }; fields = { front: front.trim(), back: back.trim() };
} }
@ -142,6 +162,8 @@
<form class="mt-6 space-y-5" onsubmit={onSubmit}> <form class="mt-6 space-y-5" onsubmit={onSubmit}>
{#if cardType === 'image-occlusion'} {#if cardType === 'image-occlusion'}
<ImageOcclusionEditor bind:imageRef bind:maskRegionsJson /> <ImageOcclusionEditor bind:imageRef bind:maskRegionsJson />
{:else if cardType === 'multiple-choice'}
<MultipleChoiceCardForm bind:front bind:mcOptions bind:mcCorrectIdx bind:mcExplanation />
{:else if cardType === 'cloze'} {:else if cardType === 'cloze'}
<div> <div>
<label class="block"> <label class="block">

View file

@ -40,6 +40,7 @@
// Multiple-Choice builder state // Multiple-Choice builder state
let mcOptions = $state(['', '', '', '']); let mcOptions = $state(['', '', '', '']);
let mcCorrectIdx = $state(0); let mcCorrectIdx = $state(0);
let mcExplanation = $state('');
const frontHtml = $derived(renderMarkdown(front)); const frontHtml = $derived(renderMarkdown(front));
const backHtml = $derived(renderMarkdown(back)); const backHtml = $derived(renderMarkdown(back));
@ -125,6 +126,7 @@
.join('\n'); .join('\n');
fields = { front: front.trim(), answer: mcOptions[mcCorrectIdx].trim() }; fields = { front: front.trim(), answer: mcOptions[mcCorrectIdx].trim() };
if (distractors) fields.distractor_pool = distractors; if (distractors) fields.distractor_pool = distractors;
if (mcExplanation.trim()) fields.explanation = mcExplanation.trim();
} else if (cardType === 'audio-front') { } else if (cardType === 'audio-front') {
fields = { audio_ref: audioFileRef.trim(), back: back.trim() }; fields = { audio_ref: audioFileRef.trim(), back: back.trim() };
} else { } else {
@ -198,7 +200,7 @@
<ClozeCardForm bind:text bind:extra {clusterIds} /> <ClozeCardForm bind:text bind:extra {clusterIds} />
{:else if cardType === 'multiple-choice'} {:else if cardType === 'multiple-choice'}
<MultipleChoiceCardForm bind:front bind:mcOptions bind:mcCorrectIdx /> <MultipleChoiceCardForm bind:front bind:mcOptions bind:mcCorrectIdx bind:mcExplanation />
{:else if cardType === 'typing'} {:else if cardType === 'typing'}
<div class="grid-2"> <div class="grid-2">

View file

@ -105,6 +105,7 @@
return { return {
answer: fields.answer ?? '', answer: fields.answer ?? '',
distractorPool: fields.distractor_pool || undefined, distractorPool: fields.distractor_pool || undefined,
explanation: fields.explanation || undefined,
}; };
}); });
@ -256,14 +257,17 @@
{revealed} {revealed}
/> />
{:else if isMultipleChoice && multipleChoiceData} {:else if isMultipleChoice && multipleChoiceData}
{#key current?.card_id}
<MultipleChoiceView <MultipleChoiceView
promptHtml={promptHtml} promptHtml={promptHtml}
answer={multipleChoiceData.answer} answer={multipleChoiceData.answer}
distractorPool={multipleChoiceData.distractorPool} distractorPool={multipleChoiceData.distractorPool}
explanation={multipleChoiceData.explanation}
deckId={deckId} deckId={deckId}
cardId={current?.card_id ?? ''} cardId={current?.card_id ?? ''}
ongrade={grade} ongrade={grade}
/> />
{/key}
{:else if isTyping && typingData} {:else if isTyping && typingData}
<TypingView <TypingView
promptHtml={promptHtml} promptHtml={promptHtml}

View file

@ -0,0 +1,116 @@
/**
* Migrates the 13 Factfulness quiz cards from type=basic (A/B/C embedded in
* front text) to type=multiple-choice with proper field structure.
* Also sets the deck to visibility=public.
*
* Run from repo root: bun --cwd apps/api run ../../scripts/migrate-factfulness-to-mc.ts
*/
import postgres from 'postgres';
const DECK_ID = '01KR96NH892HBP1TV5M3XJTB39';
const DB_URL = 'postgresql://cards:cards@localhost:5435/cards';
const sql = postgres(DB_URL);
interface CardRow {
id: string;
fields: { front?: string; back?: string };
}
function parseQuizCard(front: string, back: string): {
question: string;
answer: string;
distractorPool: string;
explanation: string;
} | null {
// front: "<question>\n\nA: opt1\nB: opt2\nC: opt3"
const splitIdx = front.indexOf('\n\nA:');
if (splitIdx === -1) return null;
const question = front.slice(0, splitIdx).trim();
const optionsBlock = front.slice(splitIdx + 2).trim(); // starts with "A: …"
const options: Record<string, string> = {};
for (const line of optionsBlock.split('\n')) {
const m = line.match(/^([A-D]):\s*(.+)$/);
if (m) options[m[1]] = m[2].trim();
}
// back: "LETTER — answer_text\n\nexplanation"
const backMatch = back.match(/^([A-D])\s*[—–-]+\s*(.+?)(?:\n\n([\s\S]*))?$/);
if (!backMatch) return null;
const correctLetter = backMatch[1];
const answer = options[correctLetter] ?? backMatch[2].trim();
const explanation = (backMatch[3] ?? '').trim();
const distractorPool = Object.entries(options)
.filter(([l]) => l !== correctLetter)
.map(([, v]) => v)
.join('\n');
return { question, answer, distractorPool, explanation };
}
async function run() {
console.log('Fetching basic cards from Factfulness deck…\n');
const rows = await sql<CardRow[]>`
SELECT id, fields
FROM cards.cards
WHERE deck_id = ${DECK_ID}
AND type = 'basic'
`;
let migrated = 0;
let skipped = 0;
for (const row of rows) {
const front = row.fields.front ?? '';
const back = row.fields.back ?? '';
if (!front.includes('\n\nA:')) {
skipped++;
continue;
}
const parsed = parseQuizCard(front, back);
if (!parsed) {
console.warn(` ⚠ Cannot parse ${row.id.slice(-8)}: "${front.slice(0, 60)}"`);
skipped++;
continue;
}
const { question, answer, distractorPool, explanation } = parsed;
const newFields: Record<string, string> = { front: question, answer };
if (distractorPool) newFields.distractor_pool = distractorPool;
if (explanation) newFields.explanation = explanation;
await sql`
UPDATE cards.cards
SET type = 'multiple-choice',
fields = ${sql.json(newFields)}
WHERE id = ${row.id}
`;
console.log(`${row.id.slice(-8)} Q: "${question.slice(0, 55)}…"`);
migrated++;
}
await sql`
UPDATE cards.decks
SET visibility = 'public'
WHERE id = ${DECK_ID}
`;
console.log(`\n ✓ Deck → visibility=public`);
console.log(`\nDone: ${migrated} migrated, ${skipped} basic cards kept as-is.`);
await sql.end();
}
run().catch((e) => {
console.error(e);
process.exit(1);
});