feat(cards): typing Card-Type mit Fuzzy-Match

- typing.ts: checkTypingAnswer (exact / close / wrong) + Levenshtein-
  Impl; close = Distanz ≤ max(1, floor(len * 0.2)); Alias-Support via
  komma-separiertes aliases-Feld
- CardTypeSchema: 'typing' ergänzt; validateFieldsForType: front+answer required
- subIndexCount: 'typing' → 1
- TypingView.svelte: Input-Feld + Submit + Result-Badge + Antwort-Markdown +
  kontext-spezifische Grade-Buttons (correct: Weiter; close: Nochmal/War richtig;
  wrong: volle 4 Buttons); svelte:window für Keyboard
- Study-Page: TypingView eingebunden, Action-Bar bei typing ausgeblendet,
  onKey delegiert zu TypingView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-10 15:23:58 +02:00
parent 1212b62613
commit 0791436107
6 changed files with 354 additions and 1 deletions

View file

@ -149,6 +149,8 @@ export function subIndexCount(type: string): number {
);
case 'audio-front':
return 1;
case 'typing':
return 1;
case 'multiple-choice':
return 1;
case 'cloze':

View file

@ -13,3 +13,4 @@ export * from './protocol/index.ts';
export * from './cloze.ts';
export * from './content-hash.ts';
export * from './image-occlusion.ts';
export * from './typing.ts';

View file

@ -11,6 +11,7 @@ export const CardTypeSchema = z.enum([
'cloze',
'image-occlusion',
'audio-front',
'typing',
]);
export type CardType = z.infer<typeof CardTypeSchema>;
@ -48,6 +49,7 @@ export function validateFieldsForType(
'type-in': ['question', 'expected'],
'image-occlusion': ['image_ref', 'mask_regions'],
'audio-front': ['audio_ref', 'back'],
typing: ['front', 'answer'],
'multiple-choice': ['question', 'options', 'correct_index'],
};
const need = required[type] ?? [];

View file

@ -0,0 +1,49 @@
export type TypingMatchResult = 'correct' | 'close' | 'wrong';
/**
* Vergleicht eine getippte Antwort mit der erwarteten Antwort.
* Normalisiert: lowercase, trim, NFD-Diakritika-Stripping.
* "close" = Levenshtein-Distanz max(1, floor(answer.length * 0.2)).
*/
export function checkTypingAnswer(
input: string,
answer: string,
aliases?: string,
): TypingMatchResult {
const norm = (s: string) =>
s
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/\p{Mn}/gu, '');
const normInput = norm(input);
const candidates = [answer, ...(aliases ? aliases.split(',') : [])]
.map(norm)
.filter((s) => s.length > 0);
if (candidates.some((c) => c === normInput)) return 'correct';
const shortestLen = Math.min(...candidates.map((c) => c.length));
const threshold = Math.max(1, Math.floor(shortestLen * 0.2));
if (candidates.some((c) => levenshtein(normInput, c) <= threshold)) return 'close';
return 'wrong';
}
function levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
const row = Array.from({ length: n + 1 }, (_, j) => j);
for (let i = 1; i <= m; i++) {
let prev = row[0];
row[0] = i;
for (let j = 1; j <= n; j++) {
const tmp = row[j];
row[j] =
a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, row[j], row[j - 1]);
prev = tmp;
}
}
return row[n];
}