Phase 8a: Cloze als MVP-Card-Type, Cluster-Counter
CardTypeSchema öffnet 'cloze' als drittes MVP-Set-Mitglied. Domain-Modul
@cards/domain/src/cloze.ts kapselt die Cluster-Logik (extractClusterIds,
subIndexCountForCloze, clusterIdForSubIndex, renderClozePrompt/Answer)
— Hint-Markup wird MVP-stumm gedroppt.
subIndexCount('cloze') wirft jetzt explizit, statt still auf 1 zu fallen,
weil die Cluster-Anzahl text-abhängig ist und ein silent-default falsch
dimensionierte Review-Tabellen produzieren würde. Card-POST-Handler holt
für Cloze die Anzahl aus subIndexCountForCloze und lehnt 422 ab, wenn
kein {{cN::…}}-Markup vorhanden ist.
12 neue Cloze-Tests, alle Domain- und API-Tests grün (41 + 46).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2bed28212d
commit
553a78d73b
9 changed files with 249 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
69
packages/cards-domain/src/cloze.ts
Normal file
69
packages/cards-domain/src/cloze.ts
Normal file
|
|
@ -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<number>();
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<typeof CardTypeSchema>;
|
||||
|
||||
/** Future-Set für Schema-Migration-Vorbereitung. */
|
||||
|
|
|
|||
74
packages/cards-domain/tests/cloze.test.ts
Normal file
74
packages/cards-domain/tests/cloze.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue