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:
parent
1212b62613
commit
0791436107
6 changed files with 354 additions and 1 deletions
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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] ?? [];
|
||||
|
|
|
|||
49
packages/cards-domain/src/typing.ts
Normal file
49
packages/cards-domain/src/typing.ts
Normal 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];
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue