diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 6ec23d9..2b9ed34 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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({ diff --git a/apps/api/src/routes/decks-generate.ts b/apps/api/src/routes/decks-generate.ts new file mode 100644 index 0000000..0aac656 --- /dev/null +++ b/apps/api/src/routes/decks-generate.ts @@ -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; + +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": "", + "deck_description": "", + "cards": [ + { "front": "", "back": "" }, + ... + ] +} + +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({ + 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; +} diff --git a/apps/api/src/services/llm-client.ts b/apps/api/src/services/llm-client.ts new file mode 100644 index 0000000..e15e468 --- /dev/null +++ b/apps/api/src/services/llm-client.ts @@ -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(opts: { + model: string; + messages: ChatMessage[]; + temperature?: number; + timeoutMs?: number; +}): Promise { + const headers: Record = { '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); + } +} diff --git a/apps/web/src/lib/api/decks.ts b/apps/web/src/lib/api/decks.ts index f692850..ecea53f 100644 --- a/apps/web/src/lib/api/decks.ts +++ b/apps/web/src/lib/api/decks.ts @@ -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, + }); +} diff --git a/apps/web/src/routes/decks/+page.svelte b/apps/web/src/routes/decks/+page.svelte index 9160c5b..98ba17c 100644 --- a/apps/web/src/routes/decks/+page.svelte +++ b/apps/web/src/routes/decks/+page.svelte @@ -45,13 +45,22 @@ } -
+
diff --git a/apps/web/src/routes/decks/new-ai/+page.svelte b/apps/web/src/routes/decks/new-ai/+page.svelte new file mode 100644 index 0000000..0223360 --- /dev/null +++ b/apps/web/src/routes/decks/new-ai/+page.svelte @@ -0,0 +1,146 @@ + + + + KI-Deck · Cards + + +
+ + ← {t('nav.decks')} + +

✨ Deck mit KI erstellen

+

+ Beschreibe ein Thema, und mana-llm baut ein Deck mit Karteikarten daraus. Du kannst die Karten + danach jederzeit editieren oder ergänzen. +

+ +
+ + +
+ + + +
+ + {#if error} + + {/if} + +
+ + {t('deck_new.cancel')} +
+ + {#if busy} +

+ mana-llm denkt nach. Bei {count} Karten typischerweise 10–60 Sekunden. +

+ {/if} +
+ + +