Phase 9i: Cloze-Hint-Anzeige

renderClozePrompt zeigt jetzt den Hint im aktiven Cluster anstelle
von „…", wenn der User die `{{c1::Antwort::Hinweis}}`-Syntax nutzt.
Beispiel: Prompt für `{{c1::Paris::Hauptstadt}}` wird "[Hauptstadt]"
statt "[…]". Nicht-aktive Cluster expandieren auf ihre Antwort —
der Hint bleibt unsichtbar, bis sein Cluster dran ist.

Neue Helper-Funktion hintForCluster(text, clusterId) liefert die
erste Hint-Annotation eines Clusters (deterministisches Verhalten
bei mehreren `{{c1::…}}`-Vorkommen mit unterschiedlichen Hints).

5 neue Tests in cloze.test.ts: hintForCluster (4 Cases), erweiterte
renderClozePrompt-Cases. Domain jetzt 46 Tests grün.

cloze_help in i18n DE/EN um die Hint-Syntax-Erklärung erweitert.
Live-Preview im Card-New/Edit nutzt die erweiterte Logik automatisch
(beide rufen renderClozePrompt aus @cards/domain).

svelte-check 379 files 0 errors, API-Tests unverändert grün
(48/9), Web-Tests 5/1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-08 18:26:00 +02:00
parent fd86d968a4
commit 4b451f1b8d
4 changed files with 60 additions and 12 deletions

View file

@ -1,17 +1,24 @@
/**
* Cloze-Helpers: Cluster-Extraktion + Sub-Index-Mapping.
* Cloze-Helpers: Cluster-Extraktion + Sub-Index-Mapping + Render.
*
* Anki-Cloze-Markup: `{{cN::answer}}` oder `{{cN::answer::hint}}`. N ist
* eine 1-basierte Cluster-ID alle Vorkommen mit gleichem N gehören zum
* selben Cluster (= ein Review). Mehrere Cluster pro Karte mehrere
* Sub-Index-Reviews.
*
* Hint (`::hint`) wird MVP-stumm fallengelassen Anki zeigt ihn unter
* der Maske, das ist Polish-Phase.
* Hint-Support: ein optionaler dritter Slot (`::hint`) liefert einen
* kurzen Tipp, der im Prompt unter der Maske erscheint. In der Antwort
* wird der Hint nicht erneut angezeigt der User hat die Antwort schon.
*/
const CLUSTER_RE = /\{\{c(\d+)::([^}]*?)(?:::([^}]*?))?\}\}/g;
export interface ClozeCluster {
id: number;
answer: string;
hint?: string;
}
/**
* Extrahiert distinct Cluster-IDs (sortiert). Aus `{{c2::a}} {{c1::b}} {{c2::c}}`
* wird `[1, 2]`. Leeres Result, wenn kein Cluster-Markup im Text.
@ -25,6 +32,19 @@ export function extractClusterIds(text: string): number[] {
return [...ids].sort((a, b) => a - b);
}
/**
* Liefert für einen Cluster den ersten gefundenen Hint, falls einer
* vorhanden ist. Mehrere Vorkommen desselben Clusters (z.B. `{{c1::a}}`
* und `{{c1::b::tip}}`) die erste Hint-Annotation gewinnt.
*/
export function hintForCluster(text: string, clusterId: number): string | undefined {
for (const match of text.matchAll(CLUSTER_RE)) {
const n = Number(match[1]);
if (n === clusterId && match[3]) return match[3];
}
return undefined;
}
/**
* Wie viele Reviews braucht eine Cloze-Karte? Ein Review pro distinct
* Cluster-ID. Gibt 0 zurück, wenn der Text gar kein Cluster-Markup enthält
@ -45,14 +65,17 @@ export function clusterIdForSubIndex(text: string, subIndex: number): number | n
}
/**
* Render-Helper für den Prompt: aktiver Cluster wird zu `[…]`, alle
* anderen Cluster werden auf ihre Antwort expandiert. Hint (`::hint`)
* wird gedroppt.
* Render-Helper für den Prompt: aktiver Cluster wird zu `[…]` (oder
* `[hint]`, falls ein Hint annotiert ist), alle anderen Cluster werden
* auf ihre Antwort expandiert.
*/
export function renderClozePrompt(text: string, activeClusterId: number): string {
return text.replace(CLUSTER_RE, (_, nStr: string, answer: string) => {
return text.replace(CLUSTER_RE, (_, nStr: string, answer: string, hint?: string) => {
const n = Number(nStr);
return n === activeClusterId ? '[…]' : answer;
if (n === activeClusterId) {
return hint ? `[${hint}]` : '[…]';
}
return answer;
});
}

View file

@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest';
import {
extractClusterIds,
hintForCluster,
subIndexCountForCloze,
clusterIdForSubIndex,
renderClozePrompt,
@ -54,15 +55,39 @@ describe('clusterIdForSubIndex', () => {
});
});
describe('hintForCluster', () => {
it('liefert den Hint, wenn vorhanden', () => {
expect(hintForCluster('Die {{c1::Antwort::Hinweis}}.', 1)).toBe('Hinweis');
});
it('liefert undefined ohne Hint-Annotation', () => {
expect(hintForCluster('Die {{c1::Antwort}}.', 1)).toBeUndefined();
});
it('liefert undefined für nicht-existierende Cluster', () => {
expect(hintForCluster('Die {{c1::Antwort::Hint}}.', 2)).toBeUndefined();
});
it('erste Hint-Annotation gewinnt bei mehreren Vorkommen', () => {
const text = '{{c1::a}} und {{c1::b::erster}} und {{c1::c::zweiter}}';
expect(hintForCluster(text, 1)).toBe('erster');
});
});
describe('renderClozePrompt', () => {
it('maskiert aktiven Cluster, expandiert andere', () => {
const out = renderClozePrompt('{{c1::A}} und {{c2::B}}', 1);
expect(out).toBe('[…] und B');
});
it('droppt Hint beim Maskieren', () => {
it('zeigt Hint im aktiven Cluster, wenn vorhanden', () => {
const out = renderClozePrompt('Die {{c1::Antwort::Hinweis}}.', 1);
expect(out).toBe('Die […].');
expect(out).toBe('Die [Hinweis].');
});
it('expandiert nicht-aktiven Cluster auch wenn Hint vorhanden', () => {
const out = renderClozePrompt('{{c1::A::tipp}} und {{c2::B}}', 2);
expect(out).toBe('A und […]');
});
});