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(),
|
||||
mcOptions = $bindable(),
|
||||
mcCorrectIdx = $bindable(),
|
||||
mcExplanation = $bindable(''),
|
||||
}: {
|
||||
front: string;
|
||||
mcOptions: string[];
|
||||
mcCorrectIdx: number;
|
||||
mcExplanation: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
|
|
@ -59,6 +61,17 @@
|
|||
</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>
|
||||
.field {
|
||||
display: flex;
|
||||
|
|
@ -73,6 +86,11 @@
|
|||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.field-optional {
|
||||
font-weight: 400;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
promptHtml,
|
||||
answer,
|
||||
distractorPool,
|
||||
explanation,
|
||||
deckId,
|
||||
cardId,
|
||||
ongrade,
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
promptHtml: string;
|
||||
answer: string;
|
||||
distractorPool?: string;
|
||||
explanation?: string;
|
||||
deckId: string;
|
||||
cardId: string;
|
||||
ongrade: (r: Rating) => void;
|
||||
|
|
@ -146,6 +148,13 @@
|
|||
{/each}
|
||||
</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}
|
||||
<button class="confirm-btn" onclick={confirm}>
|
||||
{correct ? 'Weiter' : 'Verstanden'} <kbd>Space</kbd>
|
||||
|
|
@ -295,6 +304,38 @@
|
|||
.option-correct .option-icon { color: hsl(var(--color-success)); }
|
||||
.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 {
|
||||
align-self: center;
|
||||
display: inline-flex;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
import { toasts } from '$lib/stores/toasts.svelte.ts';
|
||||
import { t } from '$lib/i18n/index.svelte.ts';
|
||||
import ImageOcclusionEditor from '$lib/components/ImageOcclusionEditor.svelte';
|
||||
import MultipleChoiceCardForm from '$lib/components/MultipleChoiceCardForm.svelte';
|
||||
|
||||
let card = $state<Card | null>(null);
|
||||
let cardType = $state<CardType>('basic');
|
||||
|
|
@ -25,6 +26,9 @@
|
|||
let extra = $state('');
|
||||
let imageRef = $state('');
|
||||
let maskRegionsJson = $state('[]');
|
||||
let mcOptions = $state(['', '', '', '']);
|
||||
let mcCorrectIdx = $state(0);
|
||||
let mcExplanation = $state('');
|
||||
let loading = $state(true);
|
||||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
|
@ -56,6 +60,14 @@
|
|||
} else if (c.type === 'image-occlusion') {
|
||||
imageRef = fields.image_ref ?? '';
|
||||
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 {
|
||||
front = fields.front ?? '';
|
||||
back = fields.back ?? '';
|
||||
|
|
@ -71,11 +83,11 @@
|
|||
|
||||
const canSave = $derived.by(() => {
|
||||
if (saving) return false;
|
||||
if (cardType === 'cloze') {
|
||||
return text.trim().length > 0 && clusterIds.length > 0;
|
||||
}
|
||||
if (cardType === 'image-occlusion') {
|
||||
return imageRef.length > 0 && maskCount > 0;
|
||||
if (cardType === 'cloze') return text.trim().length > 0 && clusterIds.length > 0;
|
||||
if (cardType === 'image-occlusion') return imageRef.length > 0 && maskCount > 0;
|
||||
if (cardType === 'multiple-choice') {
|
||||
const filledCount = mcOptions.filter((o) => o.trim()).length;
|
||||
return front.trim().length > 0 && mcOptions[mcCorrectIdx].trim().length > 0 && filledCount >= 2;
|
||||
}
|
||||
return front.trim().length > 0 && back.trim().length > 0;
|
||||
});
|
||||
|
|
@ -92,6 +104,14 @@
|
|||
: { text: text.trim() };
|
||||
} else if (cardType === 'image-occlusion') {
|
||||
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 {
|
||||
fields = { front: front.trim(), back: back.trim() };
|
||||
}
|
||||
|
|
@ -142,6 +162,8 @@
|
|||
<form class="mt-6 space-y-5" onsubmit={onSubmit}>
|
||||
{#if cardType === 'image-occlusion'}
|
||||
<ImageOcclusionEditor bind:imageRef bind:maskRegionsJson />
|
||||
{:else if cardType === 'multiple-choice'}
|
||||
<MultipleChoiceCardForm bind:front bind:mcOptions bind:mcCorrectIdx bind:mcExplanation />
|
||||
{:else if cardType === 'cloze'}
|
||||
<div>
|
||||
<label class="block">
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
// Multiple-Choice builder state
|
||||
let mcOptions = $state(['', '', '', '']);
|
||||
let mcCorrectIdx = $state(0);
|
||||
let mcExplanation = $state('');
|
||||
|
||||
const frontHtml = $derived(renderMarkdown(front));
|
||||
const backHtml = $derived(renderMarkdown(back));
|
||||
|
|
@ -125,6 +126,7 @@
|
|||
.join('\n');
|
||||
fields = { front: front.trim(), answer: mcOptions[mcCorrectIdx].trim() };
|
||||
if (distractors) fields.distractor_pool = distractors;
|
||||
if (mcExplanation.trim()) fields.explanation = mcExplanation.trim();
|
||||
} else if (cardType === 'audio-front') {
|
||||
fields = { audio_ref: audioFileRef.trim(), back: back.trim() };
|
||||
} else {
|
||||
|
|
@ -198,7 +200,7 @@
|
|||
<ClozeCardForm bind:text bind:extra {clusterIds} />
|
||||
|
||||
{: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'}
|
||||
<div class="grid-2">
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@
|
|||
return {
|
||||
answer: fields.answer ?? '',
|
||||
distractorPool: fields.distractor_pool || undefined,
|
||||
explanation: fields.explanation || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -256,14 +257,17 @@
|
|||
{revealed}
|
||||
/>
|
||||
{:else if isMultipleChoice && multipleChoiceData}
|
||||
{#key current?.card_id}
|
||||
<MultipleChoiceView
|
||||
promptHtml={promptHtml}
|
||||
answer={multipleChoiceData.answer}
|
||||
distractorPool={multipleChoiceData.distractorPool}
|
||||
explanation={multipleChoiceData.explanation}
|
||||
deckId={deckId}
|
||||
cardId={current?.card_id ?? ''}
|
||||
ongrade={grade}
|
||||
/>
|
||||
{/key}
|
||||
{:else if isTyping && typingData}
|
||||
<TypingView
|
||||
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