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
|
|
@ -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({
|
||||
|
|
|
|||
192
apps/api/src/routes/decks-generate.ts
Normal file
192
apps/api/src/routes/decks-generate.ts
Normal 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;
|
||||
}
|
||||
75
apps/api/src/services/llm-client.ts
Normal file
75
apps/api/src/services/llm-client.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,14 +45,23 @@
|
|||
}
|
||||
</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>
|
||||
<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">
|
||||
<InboxBanner />
|
||||
|
|
|
|||
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