From 2bcc3954ea9d951801c358e3cbd2caddf7938487 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 19 Apr 2026 19:29:35 +0200 Subject: [PATCH] feat(ai): add Quiz tools (create_quiz, add_quiz_question, list_quizzes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quiz is now an AI-accessible module. The agent can mint empty quizzes and append questions across all four types (single / multi / truefalse / text) via a single add_quiz_question tool whose optionsJson payload shape is documented in the catalog description. list_quizzes (auto) returns decrypted metadata so the planner can reference existing quizzes when extending them. Enables missions like "baue ein Quiz aus meinen Notizen zu Thema X" — planner reads via list_notes, proposes create_quiz, then N × add_quiz_question. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/mana/CLAUDE.md | 3 +- apps/mana/apps/web/src/lib/data/tools/init.ts | 2 + .../apps/web/src/lib/modules/quiz/tools.ts | 266 ++++++++++++++++++ packages/shared-ai/src/tools/schemas.ts | 74 +++++ 4 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 apps/mana/apps/web/src/lib/modules/quiz/tools.ts diff --git a/apps/mana/CLAUDE.md b/apps/mana/CLAUDE.md index 75ffb4aab..5f973a105 100644 --- a/apps/mana/CLAUDE.md +++ b/apps/mana/CLAUDE.md @@ -206,7 +206,7 @@ The companion is a **second actor** that works alongside the human in every modu - **Scene lens** — workbench scenes can bind to an agent via `scene.viewingAsAgentId` (context menu → "An Agent binden…"). Pure UI lens, not a data-scope change. `SceneAppBar` shows the agent avatar on bound scene tabs. - **Workbench timeline** — `/ai-workbench` renders every AI-attributed event grouped by mission iteration with per-**agent** filter, per-module, per-mission. Each bucket header shows agent avatar + name + mission title. Per-bucket **Revert button** undoes the iteration's writes via `data/ai/revert/` (TaskCreated → delete, TaskCompleted → uncomplete, etc., newest-first). Separate **"Datenzugriff"** tab exposes the server-side decrypt audit (for missions with Key-Grants). -### Tool Coverage (28 tools, 11 modules) +### Tool Coverage (32 tools, 12 modules) Agents interact with the app through tools — each one either auto (executes silently during reasoning) or propose (creates a Proposal card the user must approve). Canonical list in `@mana/shared-ai/src/policy/proposable-tools.ts`; server-side definitions in `services/mana-ai/src/planner/tools.ts`; webapp auto-tool list in `src/lib/data/ai/policy.ts`. @@ -223,6 +223,7 @@ Agents interact with the app through tools — each one either auto (executes si | journal | `create_journal_entry` | — | | habits | `create_habit`, `log_habit` | `get_habits` | | contacts | `create_contact` | `get_contacts` | +| quiz | `create_quiz`, `add_quiz_question` | `list_quizzes` | **Server-side web-research**: mana-ai calls mana-api's `/api/v1/news-research/discover` + `/search` directly before the planner prompt is built (pre-planning injection). Missions with research-keyword objectives get real article URLs + excerpts injected as a synthetic ResolvedInput. See `services/mana-ai/src/planner/news-research-client.ts`. diff --git a/apps/mana/apps/web/src/lib/data/tools/init.ts b/apps/mana/apps/web/src/lib/data/tools/init.ts index 6359f8468..dbf53975d 100644 --- a/apps/mana/apps/web/src/lib/data/tools/init.ts +++ b/apps/mana/apps/web/src/lib/data/tools/init.ts @@ -40,6 +40,7 @@ import { goalsTools } from '$lib/modules/goals/tools'; import { moodTools } from '$lib/modules/mood/tools'; import { wishesTools } from '$lib/modules/wishes/tools'; import { wetterTools } from '$lib/modules/wetter/tools'; +import { quizTools } from '$lib/modules/quiz/tools'; let initialized = false; @@ -81,5 +82,6 @@ export function initTools(): void { registerTools(moodTools); registerTools(wishesTools); registerTools(wetterTools); + registerTools(quizTools); initialized = true; } diff --git a/apps/mana/apps/web/src/lib/modules/quiz/tools.ts b/apps/mana/apps/web/src/lib/modules/quiz/tools.ts new file mode 100644 index 000000000..e1862770c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/quiz/tools.ts @@ -0,0 +1,266 @@ +/** + * Quiz tools — AI-accessible read + write over the encrypted quiz tables. + * + * - `create_quiz` (propose) — mints an empty quiz shell. + * - `add_quiz_question` (propose) — appends a question; options payload is + * shape-dependent on the question type (see description in the catalog). + * - `list_quizzes` (auto) — returns decrypted metadata so the planner + * can reference an existing quiz when extending it. + */ + +import type { ModuleTool } from '$lib/data/tools/types'; +import { quizzesStore } from './stores/quizzes.svelte'; +import { toQuiz } from './queries'; +import { db } from '$lib/data/database'; +import { decryptRecords, VaultLockedError } from '$lib/data/crypto'; +import type { LocalQuiz, QuestionOption, QuestionType } from './types'; + +const MAX_LIST_LIMIT = 100; +const DEFAULT_LIST_LIMIT = 30; + +function parseQuestionOptions( + type: QuestionType, + raw: string +): { options: QuestionOption[] } | { error: string } { + const trimmed = raw.trim(); + if (!trimmed) return { error: 'optionsJson darf nicht leer sein' }; + + if (type === 'truefalse') { + const lower = trimmed.toLowerCase(); + if (lower !== 'true' && lower !== 'false') { + return { error: 'optionsJson muss "true" oder "false" sein' }; + } + return { + options: [ + { id: crypto.randomUUID(), text: 'Wahr', isCorrect: lower === 'true' }, + { id: crypto.randomUUID(), text: 'Falsch', isCorrect: lower === 'false' }, + ], + }; + } + + if (type === 'text') { + return { options: [{ id: crypto.randomUUID(), text: trimmed, isCorrect: true }] }; + } + + // single / multi + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return { error: 'optionsJson ist kein gültiges JSON' }; + } + if (!Array.isArray(parsed) || parsed.length < 2) { + return { error: 'optionsJson muss ein Array mit mindestens 2 Einträgen sein' }; + } + + const options: QuestionOption[] = []; + for (const entry of parsed) { + if (typeof entry !== 'object' || entry === null) { + return { error: 'Jeder Eintrag muss ein Objekt {text, correct} sein' }; + } + const rec = entry as Record; + if (typeof rec.text !== 'string' || !rec.text.trim()) { + return { error: 'Jeder Eintrag braucht einen nicht-leeren text' }; + } + options.push({ + id: crypto.randomUUID(), + text: rec.text, + isCorrect: Boolean(rec.correct), + }); + } + if (!options.some((o) => o.isCorrect)) { + return { error: 'Mindestens eine Option muss correct:true haben' }; + } + if (type === 'single' && options.filter((o) => o.isCorrect).length > 1) { + return { error: 'Single-Choice erlaubt nur genau eine correct:true Option' }; + } + return { options }; +} + +async function readLocalQuiz(id: string): Promise { + const local = await db.table('quizzes').get(id); + if (!local || local.deletedAt) return null; + try { + const [decrypted] = await decryptRecords('quizzes', [local]); + return decrypted ?? null; + } catch (err) { + if (err instanceof VaultLockedError) { + throw new Error( + `Vault ist gesperrt — Quiz ${id} kann nicht entschlüsselt werden. Bitte Vault entsperren.` + ); + } + throw err; + } +} + +export const quizTools: ModuleTool[] = [ + { + name: 'create_quiz', + module: 'quiz', + description: 'Erstellt ein neues leeres Quiz mit Titel und optionaler Kategorie', + parameters: [ + { name: 'title', type: 'string', description: 'Titel des Quiz', required: true }, + { + name: 'description', + type: 'string', + description: 'Optionale Beschreibung', + required: false, + }, + { + name: 'category', + type: 'string', + description: 'Optionale Kategorie (z.B. "Geografie")', + required: false, + }, + ], + async execute(params) { + const title = String(params.title ?? '').trim(); + if (!title) return { success: false, message: 'title darf nicht leer sein' }; + + const quiz = await quizzesStore.createQuiz({ + title, + description: (params.description as string) ?? null, + category: (params.category as string) ?? null, + }); + return { + success: true, + data: quiz, + message: `Quiz "${quiz.title}" erstellt`, + }; + }, + }, + { + name: 'add_quiz_question', + module: 'quiz', + description: + 'Fügt einem bestehenden Quiz eine Frage hinzu. optionsJson-Format ist abhängig vom type: single/multi => JSON-Array [{"text":"...","correct":true|false}]; truefalse => "true" oder "false"; text => erwartete Antwort als Klartext', + parameters: [ + { name: 'quizId', type: 'string', description: 'ID des Quiz', required: true }, + { + name: 'type', + type: 'string', + description: 'Fragetyp', + required: true, + enum: ['single', 'multi', 'truefalse', 'text'], + }, + { name: 'questionText', type: 'string', description: 'Die Fragestellung', required: true }, + { + name: 'optionsJson', + type: 'string', + description: 'Antwortdaten — Format abhängig von type', + required: true, + }, + { + name: 'explanation', + type: 'string', + description: 'Optionale Erklärung, die nach dem Beantworten angezeigt wird', + required: false, + }, + ], + async execute(params) { + const quizId = String(params.quizId ?? ''); + const type = params.type as QuestionType; + const questionText = String(params.questionText ?? '').trim(); + const optionsJsonRaw = String(params.optionsJson ?? ''); + const explanation = (params.explanation as string) ?? null; + + if (!['single', 'multi', 'truefalse', 'text'].includes(type)) { + return { success: false, message: `Unbekannter Fragetyp: ${type}` }; + } + if (!questionText) { + return { success: false, message: 'questionText darf nicht leer sein' }; + } + + const existing = await readLocalQuiz(quizId); + if (!existing) return { success: false, message: `Quiz ${quizId} nicht gefunden` }; + + const parsed = parseQuestionOptions(type, optionsJsonRaw); + if ('error' in parsed) { + return { success: false, message: parsed.error }; + } + + await quizzesStore.addQuestion(quizId, { + type, + questionText, + options: parsed.options, + explanation, + }); + + const newCount = (existing.questionCount ?? 0) + 1; + return { + success: true, + data: { quizId, questionCount: newCount }, + message: `Frage #${newCount} zu "${existing.title}" hinzugefügt`, + }; + }, + }, + { + name: 'list_quizzes', + module: 'quiz', + description: + 'Listet vorhandene Quizze (id, title, category, questionCount) damit du sie referenzieren kannst', + parameters: [ + { + name: 'limit', + type: 'number', + description: `Maximale Anzahl (Standard ${DEFAULT_LIST_LIMIT}, max ${MAX_LIST_LIMIT})`, + required: false, + }, + { + name: 'query', + type: 'string', + description: 'Case-insensitive Substring-Filter auf Titel oder Kategorie', + required: false, + }, + ], + async execute(params) { + const limit = Math.min( + Math.max(Number(params.limit) || DEFAULT_LIST_LIMIT, 1), + MAX_LIST_LIMIT + ); + const query = typeof params.query === 'string' ? params.query.toLowerCase().trim() : ''; + + try { + const all = await db.table('quizzes').toArray(); + const visible = all.filter((q) => !q.deletedAt && !q.isArchived); + const decrypted = await decryptRecords('quizzes', visible); + const rows = decrypted + .map(toQuiz) + .filter((q) => { + if (!query) return true; + return ( + q.title.toLowerCase().includes(query) || + (q.category ?? '').toLowerCase().includes(query) + ); + }) + .sort((a, b) => { + if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1; + return b.updatedAt.localeCompare(a.updatedAt); + }) + .slice(0, limit) + .map((q) => ({ + id: q.id, + title: q.title, + category: q.category, + questionCount: q.questionCount, + isPinned: q.isPinned, + updatedAt: q.updatedAt, + })); + + return { + success: true, + data: { quizzes: rows, total: rows.length }, + message: `${rows.length} Quiz(ze) gelistet`, + }; + } catch (err) { + if (err instanceof VaultLockedError) { + return { + success: false, + message: 'Vault ist gesperrt — Quizze können nicht entschlüsselt werden', + }; + } + throw err; + } + }, + }, +]; diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts index 4cee09491..11aae9764 100644 --- a/packages/shared-ai/src/tools/schemas.ts +++ b/packages/shared-ai/src/tools/schemas.ts @@ -951,6 +951,80 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ }, ], }, + + // ── Quiz ────────────────────────────────────────────────── + { + name: 'create_quiz', + module: 'quiz', + description: 'Erstellt ein neues leeres Quiz mit Titel und optionaler Kategorie', + defaultPolicy: 'propose', + parameters: [ + { name: 'title', type: 'string', description: 'Titel des Quiz', required: true }, + { + name: 'description', + type: 'string', + description: 'Optionale Beschreibung', + required: false, + }, + { + name: 'category', + type: 'string', + description: 'Optionale Kategorie (z.B. "Geografie")', + required: false, + }, + ], + }, + { + name: 'add_quiz_question', + module: 'quiz', + description: + 'Fuegt einem bestehenden Quiz eine Frage hinzu. optionsJson-Format ist abhaengig vom type: single/multi => JSON-Array [{"text":"...","correct":true|false}] mit mindestens zwei Eintraegen und mindestens einem correct:true; truefalse => "true" oder "false" als korrekte Antwort; text => die erwartete Antwort als Klartext (Case-insensitive verglichen)', + defaultPolicy: 'propose', + parameters: [ + { name: 'quizId', type: 'string', description: 'ID des Quiz', required: true }, + { + name: 'type', + type: 'string', + description: 'Fragetyp', + required: true, + enum: ['single', 'multi', 'truefalse', 'text'], + }, + { name: 'questionText', type: 'string', description: 'Die Fragestellung', required: true }, + { + name: 'optionsJson', + type: 'string', + description: 'Antwortdaten — Format abhaengig von type (siehe Tool-Beschreibung)', + required: true, + }, + { + name: 'explanation', + type: 'string', + description: 'Optionale Erklaerung, die nach dem Beantworten angezeigt wird', + required: false, + }, + ], + }, + { + name: 'list_quizzes', + module: 'quiz', + description: + 'Listet vorhandene Quizze (id, title, category, questionCount) damit du sie referenzieren kannst', + defaultPolicy: 'auto', + parameters: [ + { + name: 'limit', + type: 'number', + description: 'Maximale Anzahl (Standard 30, max 100)', + required: false, + }, + { + name: 'query', + type: 'string', + description: 'Case-insensitive Substring-Filter auf Titel oder Kategorie', + required: false, + }, + ], + }, ]; // ═══════════════════════════════════════════════════════════════