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

@ -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>