diff --git a/apps/api/src/routes/cards.ts b/apps/api/src/routes/cards.ts index 9243b45..77d1e7f 100644 --- a/apps/api/src/routes/cards.ts +++ b/apps/api/src/routes/cards.ts @@ -1,7 +1,13 @@ import { and, eq } from 'drizzle-orm'; import { Hono } from 'hono'; -import { CardCreateSchema, CardUpdateSchema, newReview, subIndexCount } from '@cards/domain'; +import { + CardCreateSchema, + CardUpdateSchema, + newReview, + subIndexCount, + subIndexCountForCloze, +} from '@cards/domain'; import { getDb, type CardsDb } from '../db/connection.ts'; import { cards, decks, reviews } from '../db/schema/index.ts'; @@ -33,6 +39,22 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }> } const userId = c.get('userId'); + // Cloze: Sub-Index-Anzahl hängt vom Cluster-Markup im Text ab. + // Eine Cloze-Karte ohne `{{cN::…}}` ist sinnlos — vor dem Deck-Lookup + // ablehnen, damit Validation-Errors konsistent 422 statt 404 sind. + let count: number; + if (parsed.data.type === 'cloze') { + count = subIndexCountForCloze(parsed.data.fields.text ?? ''); + if (count === 0) { + return c.json( + { error: 'invalid_input', issues: ['cloze.text contains no {{cN::…}} clusters'] }, + 422 + ); + } + } else { + count = subIndexCount(parsed.data.type); + } + const [deck] = await dbOf() .select({ id: decks.id, userId: decks.userId }) .from(decks) @@ -43,7 +65,7 @@ export function cardsRouter(deps: CardsDeps = {}): Hono<{ Variables: AuthVars }> const cardId = ulid(); const now = new Date(); - const subIndices = Array.from({ length: subIndexCount(parsed.data.type) }, (_, i) => i); + const subIndices = Array.from({ length: count }, (_, i) => i); const [cardRow] = await dbOf().transaction(async (tx) => { const [card] = await tx diff --git a/apps/api/tests/cards.test.ts b/apps/api/tests/cards.test.ts index d8bce5f..0658d22 100644 --- a/apps/api/tests/cards.test.ts +++ b/apps/api/tests/cards.test.ts @@ -62,13 +62,59 @@ describe('cardsRouter — Input-Validation', () => { headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, body: JSON.stringify({ deck_id: 'd-1', - type: 'cloze', - fields: { text: 'x' }, + type: 'image-occlusion', + fields: { image_ref: 'x', mask_regions: 'y' }, }), }); expect(res.status).toBe(422); }); + it('POST mit cloze-Card ohne text-Feld ist 422', async () => { + const { app } = buildApp(); + const res = await app.request('/api/v1/cards', { + method: 'POST', + headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + deck_id: 'd-1', + type: 'cloze', + fields: {}, + }), + }); + expect(res.status).toBe(422); + }); + + it('POST mit cloze-Card aber Text ohne Cluster ist 422', async () => { + const { app } = buildApp(); + const res = await app.request('/api/v1/cards', { + method: 'POST', + headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + deck_id: 'd-1', + type: 'cloze', + fields: { text: 'plain text without any {{cN::…}} markup' }, + }), + }); + expect(res.status).toBe(422); + const body = (await res.json()) as { error: string; issues: string[] }; + expect(body.issues[0]).toMatch(/cloze\.text/); + }); + + it('POST mit gültiger cloze-Card erreicht Deck-Lookup (404 bei stub)', async () => { + const { app } = buildApp(); + const res = await app.request('/api/v1/cards', { + method: 'POST', + headers: { 'X-User-Id': 'u-1', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + deck_id: 'd-1', + type: 'cloze', + fields: { text: 'Capital of {{c1::France}} is {{c2::Paris}}.' }, + }), + }); + expect(res.status).toBe(404); + const body = (await res.json()) as { error: string }; + expect(body.error).toBe('deck_not_found'); + }); + it('PATCH mit extra prop ist 422', async () => { const { app } = buildApp(); const res = await app.request('/api/v1/cards/c-1', { diff --git a/packages/cards-domain/src/cloze.ts b/packages/cards-domain/src/cloze.ts new file mode 100644 index 0000000..6272a2f --- /dev/null +++ b/packages/cards-domain/src/cloze.ts @@ -0,0 +1,69 @@ +/** + * Cloze-Helpers: Cluster-Extraktion + Sub-Index-Mapping. + * + * 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. + */ + +const CLUSTER_RE = /\{\{c(\d+)::([^}]*?)(?:::([^}]*?))?\}\}/g; + +/** + * Extrahiert distinct Cluster-IDs (sortiert). Aus `{{c2::a}} {{c1::b}} {{c2::c}}` + * wird `[1, 2]`. Leeres Result, wenn kein Cluster-Markup im Text. + */ +export function extractClusterIds(text: string): number[] { + const ids = new Set(); + for (const match of text.matchAll(CLUSTER_RE)) { + const n = Number(match[1]); + if (Number.isInteger(n) && n >= 1) ids.add(n); + } + return [...ids].sort((a, b) => a - b); +} + +/** + * 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 + * — der Caller muss diesen Fall als Validation-Error behandeln, weil eine + * Cloze-Karte ohne Cluster sinnlos wäre. + */ +export function subIndexCountForCloze(text: string): number { + return extractClusterIds(text).length; +} + +/** + * Mapping Sub-Index → Cluster-ID. Sub-Index 0 = niedrigste Cluster-ID, + * 1 = nächste, usw. Für den Card-Insert-Pfad und das Study-Render. + */ +export function clusterIdForSubIndex(text: string, subIndex: number): number | null { + const ids = extractClusterIds(text); + return ids[subIndex] ?? null; +} + +/** + * Render-Helper für den Prompt: aktiver Cluster wird zu `[…]`, alle + * anderen Cluster werden auf ihre Antwort expandiert. Hint (`::hint`) + * wird gedroppt. + */ +export function renderClozePrompt(text: string, activeClusterId: number): string { + return text.replace(CLUSTER_RE, (_, nStr: string, answer: string) => { + const n = Number(nStr); + return n === activeClusterId ? '[…]' : answer; + }); +} + +/** + * Render-Helper für die Antwort: alle Cluster werden expandiert. Aktiver + * Cluster wird optisch markiert (Markdown-Bold), damit der User sieht, + * was er gerade beantworten sollte. + */ +export function renderClozeAnswer(text: string, activeClusterId: number): string { + return text.replace(CLUSTER_RE, (_, nStr: string, answer: string) => { + const n = Number(nStr); + return n === activeClusterId ? `**${answer}**` : answer; + }); +} diff --git a/packages/cards-domain/src/fsrs.ts b/packages/cards-domain/src/fsrs.ts index 3b1b019..0c29cc3 100644 --- a/packages/cards-domain/src/fsrs.ts +++ b/packages/cards-domain/src/fsrs.ts @@ -130,8 +130,10 @@ export function fromFsrsCard(prev: Review, fc: FsrsCard): Review { * um die `(card_id, sub_index)`-Reihen zu initialisieren. * * Cloze ist Sonderfall: `subIndex` hängt von der Anzahl der Cluster - * im `fields.text` ab; `subIndexCountForCloze(text)` muss separat - * gerufen werden, sobald wir Cloze unterstützen. + * im `fields.text` ab. Für Cloze MUSS der Caller `subIndexCountForCloze` + * aus `./cloze.ts` rufen — diese Funktion wirft, weil ein stiller + * Default-Fallback (z.B. 1) zu falsch dimensionierten Review-Tabellen + * führen würde. */ export function subIndexCount(type: string): number { switch (type) { @@ -147,6 +149,10 @@ export function subIndexCount(type: string): number { return 1; case 'multiple-choice': return 1; + case 'cloze': + throw new Error( + 'subIndexCount("cloze") not supported — use subIndexCountForCloze(text) from @cards/domain' + ); default: return 1; } diff --git a/packages/cards-domain/src/index.ts b/packages/cards-domain/src/index.ts index 4d82e68..e6096fb 100644 --- a/packages/cards-domain/src/index.ts +++ b/packages/cards-domain/src/index.ts @@ -10,4 +10,4 @@ export * from './schemas/index.ts'; export * from './fsrs.ts'; export * from './protocol/index.ts'; -// export * from './cloze.ts'; // Phase 8 oder später: Cloze-Parser +export * from './cloze.ts'; diff --git a/packages/cards-domain/src/schemas/card.ts b/packages/cards-domain/src/schemas/card.ts index 13dc44d..45b6e0c 100644 --- a/packages/cards-domain/src/schemas/card.ts +++ b/packages/cards-domain/src/schemas/card.ts @@ -1,10 +1,11 @@ import { z } from 'zod'; /** - * MVP-CardType-Set. Erweiterung in CARDS_GREENFIELD.md Phase 8+ vorgesehen - * (cloze, type-in, image-occlusion, audio, multiple-choice). + * MVP-CardType-Set. Cloze in Phase 8 (Anki-Import) ergänzt; weitere + * Erweiterung (type-in, image-occlusion, audio, multiple-choice) + * vorbereitet im CardTypeFutureSchema. */ -export const CardTypeSchema = z.enum(['basic', 'basic-reverse']); +export const CardTypeSchema = z.enum(['basic', 'basic-reverse', 'cloze']); export type CardType = z.infer; /** Future-Set für Schema-Migration-Vorbereitung. */ diff --git a/packages/cards-domain/tests/cloze.test.ts b/packages/cards-domain/tests/cloze.test.ts new file mode 100644 index 0000000..8a030d8 --- /dev/null +++ b/packages/cards-domain/tests/cloze.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; + +import { + extractClusterIds, + subIndexCountForCloze, + clusterIdForSubIndex, + renderClozePrompt, + renderClozeAnswer, +} from '../src/cloze.ts'; + +describe('extractClusterIds', () => { + it('liefert leere Liste, wenn kein Cluster-Markup', () => { + expect(extractClusterIds('Plain text without cloze')).toEqual([]); + }); + + it('extrahiert einen einzelnen Cluster', () => { + expect(extractClusterIds('The capital of France is {{c1::Paris}}.')).toEqual([1]); + }); + + it('extrahiert mehrere Cluster aufsteigend sortiert', () => { + expect(extractClusterIds('{{c2::B}} kommt nach {{c1::A}}')).toEqual([1, 2]); + }); + + it('dedupliziert mehrfach auftretende Cluster-IDs', () => { + expect(extractClusterIds('{{c1::a}} und {{c1::b}} und {{c2::c}}')).toEqual([1, 2]); + }); + + it('ignoriert malformed Cluster (kein numerisches N)', () => { + expect(extractClusterIds('{{cX::weird}} {{c1::ok}}')).toEqual([1]); + }); + + it('akzeptiert Cluster mit Hint (::hint wird gedroppt)', () => { + expect(extractClusterIds('Die {{c1::Hauptstadt::Land}} von Frankreich.')).toEqual([1]); + }); +}); + +describe('subIndexCountForCloze', () => { + it('zählt distinct Cluster', () => { + expect(subIndexCountForCloze('{{c1::a}} {{c2::b}} {{c3::c}}')).toBe(3); + }); + + it('returniert 0 bei text ohne Cluster', () => { + expect(subIndexCountForCloze('keine cloze')).toBe(0); + }); +}); + +describe('clusterIdForSubIndex', () => { + it('mapt subIndex auf sortierte Cluster-IDs', () => { + const text = '{{c3::c}} {{c1::a}} {{c2::b}}'; + expect(clusterIdForSubIndex(text, 0)).toBe(1); + expect(clusterIdForSubIndex(text, 1)).toBe(2); + expect(clusterIdForSubIndex(text, 2)).toBe(3); + expect(clusterIdForSubIndex(text, 3)).toBe(null); + }); +}); + +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', () => { + const out = renderClozePrompt('Die {{c1::Antwort::Hinweis}}.', 1); + expect(out).toBe('Die […].'); + }); +}); + +describe('renderClozeAnswer', () => { + it('expandiert alle Cluster, markiert aktiven mit Bold', () => { + const out = renderClozeAnswer('{{c1::A}} und {{c2::B}}', 1); + expect(out).toBe('**A** und B'); + }); +}); diff --git a/packages/cards-domain/tests/fsrs.test.ts b/packages/cards-domain/tests/fsrs.test.ts index 43cb783..cdd35c2 100644 --- a/packages/cards-domain/tests/fsrs.test.ts +++ b/packages/cards-domain/tests/fsrs.test.ts @@ -81,6 +81,10 @@ describe('subIndexCount', () => { it('unknown type defaults to 1', () => { expect(subIndexCount('unknown-future-type')).toBe(1); }); + + it('cloze wirft — Caller muss subIndexCountForCloze nutzen', () => { + expect(() => subIndexCount('cloze')).toThrow(/subIndexCountForCloze/); + }); }); describe('buildScheduler', () => { diff --git a/packages/cards-domain/tests/schemas.test.ts b/packages/cards-domain/tests/schemas.test.ts index 9610770..f35874f 100644 --- a/packages/cards-domain/tests/schemas.test.ts +++ b/packages/cards-domain/tests/schemas.test.ts @@ -14,10 +14,14 @@ describe('CardTypeSchema', () => { it('accepts MVP types', () => { expect(() => CardTypeSchema.parse('basic')).not.toThrow(); expect(() => CardTypeSchema.parse('basic-reverse')).not.toThrow(); + expect(() => CardTypeSchema.parse('cloze')).not.toThrow(); }); - it('rejects future types in MVP schema', () => { - expect(() => CardTypeSchema.parse('cloze')).toThrow(); + it('rejects future types not yet in MVP schema', () => { + expect(() => CardTypeSchema.parse('image-occlusion')).toThrow(); + expect(() => CardTypeSchema.parse('type-in')).toThrow(); + expect(() => CardTypeSchema.parse('audio')).toThrow(); + expect(() => CardTypeSchema.parse('multiple-choice')).toThrow(); }); }); @@ -55,11 +59,20 @@ describe('CardCreateSchema', () => { expect(r.success).toBe(false); }); - it('rejects unknown type via CardTypeSchema', () => { + it('accepts a cloze card with text field', () => { const r = CardCreateSchema.safeParse({ deck_id: 'd-1', type: 'cloze', - fields: { text: 'x' }, + fields: { text: '{{c1::Paris}} ist die Hauptstadt.' }, + }); + expect(r.success).toBe(true); + }); + + it('rejects unknown type via CardTypeSchema', () => { + const r = CardCreateSchema.safeParse({ + deck_id: 'd-1', + type: 'image-occlusion', + fields: { image_ref: 'x', mask_regions: 'y' }, }); expect(r.success).toBe(false); });