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
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