Phase 9m: KI-Deck-Generation via mana-llm
Some checks are pending
CI / validate (push) Waiting to run

Server-side AI-Pfad mit atomischer Deck+Cards-Erzeugung:

  POST /api/v1/decks/generate
    body { prompt, language?: 'de'|'en', count?: 3..40 }
  → ruft mana-llm /v1/chat/completions mit `mana/structured`-Alias
    (JSON-Output, hartes zod-Schema)
  → SystemPrompt fixiert das Output-Format (deck_name + cards mit
    front/back), verbietet HTML/Code-Fences, akzeptiert Markdown
  → Validation: zod-strict, halluzinations-resilient
  → Insert: Deck + alle Karten + Reviews in einer DB-Transaction,
    contentHash beim Insert geschrieben (Phase-9j-konform)
  → 502 wenn LLM Schema bricht oder Endpoint timeoutet (90s cap)

Frontend:
  - Neue Route /decks/new-ai mit Prompt-Form, Anzahl-Karten-Slider
    (3-40), Sprach-Wähler (DE/EN, default = aktuelle UI-Sprache).
  - 5 klickbare Beispiel-Prompts als Inspiration.
  - busy-State zeigt "10-60s typisch" (Disclaimer für die LLM-Latenz).
  - " KI-Deck"-Button neben "Neues Deck" auf /decks.
  - error-Display mit role=alert.

apps/api/src/services/llm-client.ts kapselt den Aufruf:
  - mana/structured als Alias (Routing-Layer wählt Provider)
  - response_format json_object
  - 90s-Timeout per AbortController
  - LlmError mit status + body für saubere 502-Mapping
  - Optional CARDS_LLM_API_KEY-Env (für später, wenn mana-llm
    GPU_API_KEY enforce'd)

Auth: aktuell User-JWT via authMiddleware. Tier-Gating bewusst
nicht aktiv — Cards-MVP ist tier-frei. Wenn AI-Generation Credits
kosten soll, kommt requireTier('beta') + creditsClient.reserve()
davor (Phase-6-Plumbing ist da, ein-Liner-Aktivierung).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-08 22:10:52 +02:00
parent 87a7a31ece
commit f11df63e7b
6 changed files with 437 additions and 6 deletions

View file

@ -12,6 +12,7 @@ import { searchRouter } from './routes/search.ts';
import { dsgvoRouter } from './routes/dsgvo.ts';
import { meRouter } from './routes/me.ts';
import { mediaRouter } from './routes/media.ts';
import { decksGenerateRouter } from './routes/decks-generate.ts';
const app = new Hono();
@ -43,6 +44,7 @@ app.route('/api/v1/search', searchRouter());
app.route('/api/v1/dsgvo', dsgvoRouter());
app.route('/api/v1/me', meRouter());
app.route('/api/v1/media', mediaRouter());
app.route('/api/v1/decks/generate', decksGenerateRouter());
app.get('/', (c) =>
c.json({

View file

@ -0,0 +1,192 @@
import { eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import { cardContentHash, newReview, subIndexCount } from '@cards/domain';
import { getDb, type CardsDb } from '../db/connection.ts';
import { cards, decks, reviews } from '../db/schema/index.ts';
import { authMiddleware, type AuthVars } from '../middleware/auth.ts';
import { ulid } from '../lib/ulid.ts';
import { chatJson } from '../services/llm-client.ts';
export type GenerateDeps = { db?: CardsDb };
const GenerateInputSchema = z.object({
prompt: z.string().min(3).max(500),
language: z.enum(['de', 'en']).optional().default('de'),
count: z.number().int().min(1).max(40).optional().default(15),
});
// Was die LLM zurückgeben muss. zod-strict damit Halluzinationen
// (extra Felder, falsche Types) hart abgelehnt werden.
const GeneratedDeckSchema = z.object({
deck_name: z.string().min(1).max(80),
deck_description: z.string().max(400).optional(),
cards: z
.array(
z.object({
front: z.string().min(1).max(800),
back: z.string().min(1).max(800),
})
)
.min(1)
.max(40),
});
type GeneratedDeck = z.infer<typeof GeneratedDeckSchema>;
const SYSTEM_PROMPT = `Du bist ein Lerndesigner und erstellst Karteikarten-Decks für Spaced-Repetition-Lernen.
Du gibst NUR ein gültiges JSON-Objekt zurück, exakt mit diesem Schema:
{
"deck_name": "<kurzer Titel, max 80 Zeichen>",
"deck_description": "<eine Zeile Beschreibung, optional>",
"cards": [
{ "front": "<Frage oder Begriff>", "back": "<Antwort oder Erklärung>" },
...
]
}
Regeln:
- Front ist Frage / Begriff / Hinweis. Back ist Antwort / Definition / Erklärung.
- Eine Karte = ein Lernstoff-Bissen (atomic). Nicht mehrere Konzepte in eine Karte stopfen.
- Markdown ist erlaubt (\`**fett**\`, \`*kursiv*\`, Listen, \`code\`).
- KEIN HTML, KEIN Code-Fence, KEINE Erklärung außerhalb des JSON.
- Wenn die User-Anfrage in einer bestimmten Sprache ist, antworte in derselben Sprache.`;
export function decksGenerateRouter(deps: GenerateDeps = {}): Hono<{ Variables: AuthVars }> {
const r = new Hono<{ Variables: AuthVars }>();
const dbOf = () => deps.db ?? getDb();
r.use('*', authMiddleware);
r.post('/', async (c) => {
const userId = c.get('userId');
const body = await c.req.json().catch(() => null);
const parsed = GenerateInputSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: 'invalid_input', issues: parsed.error.issues.map((i) => i.message) },
422
);
}
const userPrompt = `Sprache: ${parsed.data.language}
Erstelle ein Deck zu folgendem Thema mit etwa ${parsed.data.count} Karten:
${parsed.data.prompt}`;
// LLM aufrufen + JSON parsen + Schema validieren.
let generated: GeneratedDeck;
try {
const raw = await chatJson<unknown>({
model: 'mana/structured',
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: userPrompt },
],
temperature: 0.7,
timeoutMs: 90_000,
});
const r2 = GeneratedDeckSchema.safeParse(raw);
if (!r2.success) {
return c.json(
{
error: 'llm_returned_invalid_shape',
issues: r2.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`),
raw,
},
502
);
}
generated = r2.data;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return c.json({ error: 'llm_call_failed', detail: msg }, 502);
}
// Deck + Karten in einer Transaction anlegen.
const deckId = ulid();
const now = new Date();
const cardRowsInsert = await Promise.all(
generated.cards.map(async (gc) => {
const id = ulid();
const fields = { front: gc.front, back: gc.back };
const contentHash = await cardContentHash({ type: 'basic', fields });
return { id, fields, contentHash };
})
);
await dbOf().transaction(async (tx) => {
await tx.insert(decks).values({
id: deckId,
userId,
name: generated.deck_name,
description: generated.deck_description ?? `KI-generiert: ${parsed.data.prompt}`,
color: '#7c3aed', // purple-600 — visuelle Markierung als KI-generiert
visibility: 'private',
fsrsSettings: {},
createdAt: now,
updatedAt: now,
});
for (const cr of cardRowsInsert) {
await tx.insert(cards).values({
id: cr.id,
deckId,
userId,
type: 'basic',
fields: cr.fields,
mediaRefs: [],
contentHash: cr.contentHash,
createdAt: now,
updatedAt: now,
});
const subIndices = Array.from({ length: subIndexCount('basic') }, (_, i) => i);
const initial = subIndices.map((subIndex) => {
const r = newReview({ userId, cardId: cr.id, subIndex, now });
return {
cardId: r.card_id,
subIndex: r.sub_index,
userId: r.user_id,
due: new Date(r.due),
stability: r.stability,
difficulty: r.difficulty,
elapsedDays: r.elapsed_days,
scheduledDays: r.scheduled_days,
learningSteps: r.learning_steps,
reps: r.reps,
lapses: r.lapses,
state: r.state,
lastReview: r.last_review ? new Date(r.last_review) : null,
};
});
await tx.insert(reviews).values(initial);
}
});
// Deck-DTO zurückgeben.
const [row] = await dbOf().select().from(decks).where(eq(decks.id, deckId)).limit(1);
return c.json(
{
deck: row
? {
id: row.id,
name: row.name,
description: row.description,
color: row.color,
visibility: row.visibility,
fsrs_settings: row.fsrsSettings,
user_id: row.userId,
created_at: row.createdAt.toISOString(),
updated_at: row.updatedAt.toISOString(),
}
: null,
cards_created: cardRowsInsert.length,
},
201
);
});
return r;
}

View file

@ -0,0 +1,75 @@
/**
* Schmaler Client gegen mana-llm (`https://llm.mana.how`).
*
* Nutzt OpenAI-kompatibles `/v1/chat/completions`. Cards verwendet
* Aliases statt konkreter Modelle, damit der Routing-Layer die
* Provider-Auswahl macht:
* - `mana/structured` für JSON-Output (Deck-Generation)
* - `mana/long-form` wenn freier Text gefragt wäre
*
* Auth: heute keine mana-llm hat `GPU_API_KEY` leer. Wenn das mal
* gesetzt wird, kommt der Key über CARDS_LLM_API_KEY rein.
*/
const LLM_URL = process.env.MANA_LLM_URL ?? 'https://llm.mana.how';
const LLM_API_KEY = process.env.CARDS_LLM_API_KEY ?? '';
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface ChatCompletion {
choices: { message: { role: string; content: string } }[];
}
export class LlmError extends Error {
constructor(
readonly status: number,
readonly body: unknown,
message?: string
) {
super(message ?? `mana-llm ${status}`);
}
}
export async function chatJson<T>(opts: {
model: string;
messages: ChatMessage[];
temperature?: number;
timeoutMs?: number;
}): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (LLM_API_KEY) headers['X-API-Key'] = LLM_API_KEY;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 60_000);
try {
const r = await fetch(`${LLM_URL}/v1/chat/completions`, {
method: 'POST',
headers,
signal: controller.signal,
body: JSON.stringify({
model: opts.model,
messages: opts.messages,
response_format: { type: 'json_object' },
temperature: opts.temperature ?? 0.7,
}),
});
if (!r.ok) {
const body = await r.text().catch(() => '');
throw new LlmError(r.status, body);
}
const data = (await r.json()) as ChatCompletion;
const content = data.choices?.[0]?.message?.content;
if (!content) throw new LlmError(0, data, 'mana-llm: empty completion');
try {
return JSON.parse(content) as T;
} catch (e) {
throw new LlmError(0, content, `mana-llm: invalid JSON (${(e as Error).message})`);
}
} finally {
clearTimeout(timer);
}
}

View file

@ -20,3 +20,10 @@ export function updateDeck(id: string, patch: DeckUpdate) {
export function deleteDeck(id: string) {
return api<{ deleted: string }>(`/api/v1/decks/${id}`, { method: 'DELETE' });
}
export function generateDeck(input: { prompt: string; language?: 'de' | 'en'; count?: number }) {
return api<{ deck: Deck; cards_created: number }>('/api/v1/decks/generate', {
method: 'POST',
body: input,
});
}

View file

@ -45,13 +45,22 @@
}
</script>
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-2">
<h1 class="text-2xl font-semibold">{t('decks.title')}</h1>
<a
href="/decks/new"
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)]"
>{t('decks.new')}</a
>
<div class="flex gap-2">
<a
href="/decks/new-ai"
class="rounded border border-[var(--color-primary)] px-3 py-2 text-sm text-[var(--color-primary)] hover:bg-[var(--color-primary)]/10"
title="Mit KI generieren"
>
✨ KI-Deck
</a>
<a
href="/decks/new"
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)]"
>{t('decks.new')}</a
>
</div>
</div>
<div class="mt-4">

View file

@ -0,0 +1,146 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
import { generateDeck } from '$lib/api/decks.ts';
import { toasts } from '$lib/stores/toasts.svelte.ts';
import { i18n, t } from '$lib/i18n/index.svelte.ts';
let prompt = $state('');
let count = $state(15);
let language = $state<'de' | 'en'>(i18n.current);
let busy = $state(false);
let error = $state<string | null>(null);
const examples = [
'Deutsche Hunderassen mit Charaktermerkmalen',
'Italienische Verben (essere, avere) Konjugation',
'Wichtige Konzepte aus 1984 von Orwell',
'Häufige React-Hooks und ihre typischen Use-Cases',
'Pflanzenfamilien mit jeweils 3 typischen Vertretern',
];
onMount(() => {
if (!devUser.id) goto('/');
});
async function onSubmit(e: SubmitEvent) {
e.preventDefault();
if (busy || !prompt.trim()) return;
busy = true;
error = null;
try {
const result = await generateDeck({
prompt: prompt.trim(),
count,
language,
});
toasts.success(`✨ Deck "${result.deck.name}" mit ${result.cards_created} Karten erstellt`);
goto(`/decks/${result.deck.id}`);
} catch (err) {
error = (err as Error).message;
} finally {
busy = false;
}
}
</script>
<svelte:head>
<title>KI-Deck · Cards</title>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8">
<a href="/decks" class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]">
{t('nav.decks')}
</a>
<h1 class="mt-2 text-2xl font-semibold">✨ Deck mit KI erstellen</h1>
<p class="mt-2 text-sm text-[var(--color-muted)]">
Beschreibe ein Thema, und mana-llm baut ein Deck mit Karteikarten daraus. Du kannst die Karten
danach jederzeit editieren oder ergänzen.
</p>
<form class="mt-6 space-y-4" onsubmit={onSubmit}>
<label class="block">
<span class="text-sm font-medium">Thema / Prompt</span>
<textarea
bind:value={prompt}
required
rows="4"
maxlength="500"
placeholder="z.B. Deutsche Hunderassen mit ihren wichtigsten Charaktermerkmalen"
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 text-sm"
></textarea>
<p class="mt-1 text-xs text-[var(--color-muted)]">
Klar formulierte, abgegrenzte Themen funktionieren besser als zu breite („alles über
Geschichte" → eher: „französische Revolution: Schlüsselereignisse 1789-1799").
</p>
</label>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="block">
<span class="text-sm font-medium">Anzahl Karten</span>
<input
type="number"
bind:value={count}
min="3"
max="40"
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 text-sm"
/>
<p class="mt-1 text-xs text-[var(--color-muted)]">340 (Server-Cap).</p>
</label>
<label class="block">
<span class="text-sm font-medium">Sprache</span>
<select
bind:value={language}
class="mt-1 block w-full rounded border bg-[var(--color-card)] border-[var(--color-border)] px-3 py-2 text-sm"
>
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</label>
</div>
{#if error}
<div class="rounded border border-[var(--color-danger)]/40 bg-[var(--color-danger)]/10 p-3 text-sm text-[var(--color-danger)]" role="alert">
{error}
</div>
{/if}
<div class="flex items-center gap-3">
<button
type="submit"
disabled={busy || !prompt.trim()}
class="rounded bg-[var(--color-primary)] px-4 py-2 text-sm text-[var(--color-primary-fg)] disabled:opacity-50"
>
{busy ? '✨ Generiere…' : '✨ Deck generieren'}
</button>
<a href="/decks" class="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
>{t('deck_new.cancel')}</a
>
</div>
{#if busy}
<p class="text-xs text-[var(--color-muted)]" aria-live="polite">
mana-llm denkt nach. Bei {count} Karten typischerweise 1060 Sekunden.
</p>
{/if}
</form>
<aside class="mt-10 rounded-lg border border-dashed border-[var(--color-border)] p-4">
<div class="text-xs font-medium text-[var(--color-fg)]">Beispiele zum Klicken:</div>
<ul class="mt-2 space-y-1.5">
{#each examples as ex (ex)}
<li>
<button
type="button"
class="text-left text-xs text-[var(--color-muted)] hover:text-[var(--color-primary)]"
onclick={() => (prompt = ex)}
>
{ex}
</button>
</li>
{/each}
</ul>
</aside>
</div>