From 4b451f1b8ddf8319e3e0c613b099a0e1290d15d3 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 8 May 2026 18:26:00 +0200 Subject: [PATCH] Phase 9i: Cloze-Hint-Anzeige MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/web/src/lib/i18n/de.ts | 2 +- apps/web/src/lib/i18n/en.ts | 2 +- packages/cards-domain/src/cloze.ts | 39 ++++++++++++++++++----- packages/cards-domain/tests/cloze.test.ts | 29 +++++++++++++++-- 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/apps/web/src/lib/i18n/de.ts b/apps/web/src/lib/i18n/de.ts index fb2a6e6..7f8589a 100644 --- a/apps/web/src/lib/i18n/de.ts +++ b/apps/web/src/lib/i18n/de.ts @@ -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)', diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 989aea8..2af2915 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -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)', diff --git a/packages/cards-domain/src/cloze.ts b/packages/cards-domain/src/cloze.ts index 6272a2f..d3633aa 100644 --- a/packages/cards-domain/src/cloze.ts +++ b/packages/cards-domain/src/cloze.ts @@ -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; }); } diff --git a/packages/cards-domain/tests/cloze.test.ts b/packages/cards-domain/tests/cloze.test.ts index 8a030d8..43b9bd4 100644 --- a/packages/cards-domain/tests/cloze.test.ts +++ b/packages/cards-domain/tests/cloze.test.ts @@ -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 […]'); }); });