Phase 9m: KI-Deck-Generation via mana-llm
Some checks are pending
CI / validate (push) Waiting to run
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:
parent
87a7a31ece
commit
f11df63e7b
6 changed files with 437 additions and 6 deletions
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
146
apps/web/src/routes/decks/new-ai/+page.svelte
Normal file
146
apps/web/src/routes/decks/new-ai/+page.svelte
Normal 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)]">3–40 (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 10–60 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue