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:
parent
41ecec16c3
commit
9839737049
6 changed files with 209 additions and 6 deletions
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
116
scripts/migrate-factfulness-to-mc.ts
Normal file
116
scripts/migrate-factfulness-to-mc.ts
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue