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

@ -74,7 +74,7 @@ export const de: TranslationNode = {
preview_label: 'Vorschau',
cloze_text_label: 'Text mit Lücken (Markdown)',
cloze_text_placeholder: 'Die Hauptstadt von {{c1::Frankreich}} ist {{c2::Paris}}.',
cloze_help: '{{c1::Antwort}} definiert eine Lücke. Pro Cluster-ID (c1, c2, …) entsteht ein eigenes Review.',
cloze_help: '{{c1::Antwort}} definiert eine Lücke. Pro Cluster-ID (c1, c2, …) entsteht ein eigenes Review. Optionaler Hinweis: {{c1::Antwort::Tipp}} — der Tipp erscheint im Prompt anstelle von „…".',
cloze_no_clusters: 'Mindestens ein {{cN::…}}-Cluster wird gebraucht.',
cloze_clusters_detected: '{n} Cluster erkannt: c{ids} → {n} Reviews.',
cloze_preview_label: 'Vorschau (c{first} maskiert)',

View file

@ -71,7 +71,7 @@ export const en: TranslationNode = {
preview_label: 'Preview',
cloze_text_label: 'Text with blanks (Markdown)',
cloze_text_placeholder: 'The capital of {{c1::France}} is {{c2::Paris}}.',
cloze_help: '{{c1::Answer}} defines a blank. Each cluster ID (c1, c2, …) becomes its own review.',
cloze_help: '{{c1::Answer}} defines a blank. Each cluster ID (c1, c2, …) becomes its own review. Optional hint: {{c1::Answer::Hint}} — the hint replaces „…" in the prompt.',
cloze_no_clusters: 'At least one {{cN::…}} cluster is required.',
cloze_clusters_detected: '{n} clusters detected: c{ids} → {n} reviews.',
cloze_preview_label: 'Preview (c{first} masked)',

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 […]');
});
});