From 7fb31e41b5659493bb69fb984cf18a2b42dba618 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 19 Apr 2026 19:50:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(ai):=20expand=20Quiz=20tools=20=E2=80=94?= =?UTF-8?q?=20edit/delete=20questions,=20edit=20meta,=20stats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Quiz CRUD surface for the AI agent. Five new tools: - update_quiz (propose) — rename/archive/pin + description/category - update_quiz_question (propose) — text, type+options, explanation; rejects a type swap without a matching optionsJson - delete_quiz_question (propose) — symmetric to add_quiz_question - get_quiz_questions (auto) — lets the planner see existing questions before appending more (avoids duplicates) - get_quiz_stats (auto) — attemptCount / avgScore / bestScore / lastAttemptAt; enables adaptive missions like "analyze my weak spots and generate harder questions" delete_quiz deliberately left out — too destructive to leave in the AI's hands when the user can delete manually in two clicks. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/mana/CLAUDE.md | 4 +- .../apps/web/src/lib/modules/quiz/tools.ts | 261 +++++++++++++++++- packages/shared-ai/src/tools/schemas.ts | 85 ++++++ 3 files changed, 346 insertions(+), 4 deletions(-) diff --git a/apps/mana/CLAUDE.md b/apps/mana/CLAUDE.md index 5f973a105..ec8c8c4ba 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 (32 tools, 12 modules) +### Tool Coverage (37 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,7 +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` | +| quiz | `create_quiz`, `update_quiz`, `add_quiz_question`, `update_quiz_question`, `delete_quiz_question` | `list_quizzes`, `get_quiz_questions`, `get_quiz_stats` | **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/modules/quiz/tools.ts b/apps/mana/apps/web/src/lib/modules/quiz/tools.ts index e1862770c..f487c9378 100644 --- a/apps/mana/apps/web/src/lib/modules/quiz/tools.ts +++ b/apps/mana/apps/web/src/lib/modules/quiz/tools.ts @@ -10,10 +10,16 @@ import type { ModuleTool } from '$lib/data/tools/types'; import { quizzesStore } from './stores/quizzes.svelte'; -import { toQuiz } from './queries'; +import { toQuiz, toQuestion } from './queries'; import { db } from '$lib/data/database'; import { decryptRecords, VaultLockedError } from '$lib/data/crypto'; -import type { LocalQuiz, QuestionOption, QuestionType } from './types'; +import type { + LocalQuiz, + LocalQuizQuestion, + LocalQuizAttempt, + QuestionOption, + QuestionType, +} from './types'; const MAX_LIST_LIMIT = 100; const DEFAULT_LIST_LIMIT = 30; @@ -93,6 +99,22 @@ async function readLocalQuiz(id: string): Promise { } } +async function readLocalQuestion(id: string): Promise { + const local = await db.table('quizQuestions').get(id); + if (!local || local.deletedAt) return null; + try { + const [decrypted] = await decryptRecords('quizQuestions', [local]); + return decrypted ?? null; + } catch (err) { + if (err instanceof VaultLockedError) { + throw new Error( + `Vault ist gesperrt — Frage ${id} kann nicht entschlüsselt werden. Bitte Vault entsperren.` + ); + } + throw err; + } +} + export const quizTools: ModuleTool[] = [ { name: 'create_quiz', @@ -129,6 +151,54 @@ export const quizTools: ModuleTool[] = [ }; }, }, + { + name: 'update_quiz', + module: 'quiz', + description: + 'Aktualisiert Metadaten eines bestehenden Quiz. Nur die mitgegebenen Felder werden geschrieben', + parameters: [ + { name: 'quizId', type: 'string', description: 'ID des Quiz', required: true }, + { name: 'title', type: 'string', description: 'Neuer Titel', required: false }, + { name: 'description', type: 'string', description: 'Neue Beschreibung', required: false }, + { name: 'category', type: 'string', description: 'Neue Kategorie', required: false }, + { name: 'isPinned', type: 'boolean', description: 'Quiz anpinnen', required: false }, + { name: 'isArchived', type: 'boolean', description: 'Quiz archivieren', required: false }, + ], + async execute(params) { + const quizId = String(params.quizId ?? ''); + const existing = await readLocalQuiz(quizId); + if (!existing) return { success: false, message: `Quiz ${quizId} nicht gefunden` }; + + const diff: Partial< + Pick + > = {}; + + if (typeof params.title === 'string') { + const t = params.title.trim(); + if (!t) return { success: false, message: 'title darf nicht leer sein' }; + diff.title = t; + } + if (typeof params.description === 'string') { + diff.description = params.description === '' ? null : params.description; + } + if (typeof params.category === 'string') { + diff.category = params.category === '' ? null : params.category; + } + if (typeof params.isPinned === 'boolean') diff.isPinned = params.isPinned; + if (typeof params.isArchived === 'boolean') diff.isArchived = params.isArchived; + + if (Object.keys(diff).length === 0) { + return { success: false, message: 'Kein Feld zum Aktualisieren angegeben' }; + } + + await quizzesStore.updateQuiz(quizId, diff); + return { + success: true, + data: { quizId, fields: Object.keys(diff) }, + message: `Quiz "${diff.title ?? existing.title}" aktualisiert`, + }; + }, + }, { name: 'add_quiz_question', module: 'quiz', @@ -194,6 +264,107 @@ export const quizTools: ModuleTool[] = [ }; }, }, + { + name: 'update_quiz_question', + module: 'quiz', + description: + 'Aktualisiert eine vorhandene Frage. Beim Ändern der Antworten müssen type + optionsJson zusammen übergeben werden. Text und Erklärung können unabhängig geändert werden', + parameters: [ + { name: 'questionId', type: 'string', description: 'ID der Frage', required: true }, + { name: 'questionText', type: 'string', description: 'Neue Fragestellung', required: false }, + { + name: 'type', + type: 'string', + description: 'Neuer Fragetyp (wenn optionsJson mitgegeben wird)', + required: false, + enum: ['single', 'multi', 'truefalse', 'text'], + }, + { + name: 'optionsJson', + type: 'string', + description: 'Neue Antwortdaten — Format abhängig vom type', + required: false, + }, + { + name: 'explanation', + type: 'string', + description: 'Neue Erklärung (Leerstring löscht)', + required: false, + }, + ], + async execute(params) { + const questionId = String(params.questionId ?? ''); + const existing = await readLocalQuestion(questionId); + if (!existing) return { success: false, message: `Frage ${questionId} nicht gefunden` }; + + const diff: Partial< + Pick + > = {}; + + if (typeof params.questionText === 'string') { + const t = params.questionText.trim(); + if (!t) return { success: false, message: 'questionText darf nicht leer sein' }; + diff.questionText = t; + } + + if (typeof params.explanation === 'string') { + diff.explanation = params.explanation === '' ? null : params.explanation; + } + + const hasOptions = typeof params.optionsJson === 'string'; + const hasType = typeof params.type === 'string'; + + if (hasOptions) { + const targetType = hasType ? (params.type as QuestionType) : existing.type; + if (!['single', 'multi', 'truefalse', 'text'].includes(targetType)) { + return { success: false, message: `Unbekannter Fragetyp: ${targetType}` }; + } + const parsed = parseQuestionOptions(targetType, String(params.optionsJson)); + if ('error' in parsed) return { success: false, message: parsed.error }; + diff.options = parsed.options; + if (hasType) diff.type = targetType; + } else if (hasType) { + return { + success: false, + message: + 'type ohne optionsJson zu ändern ist nicht erlaubt — die Antworten würden nicht mehr zum Typ passen', + }; + } + + if (Object.keys(diff).length === 0) { + return { success: false, message: 'Kein Feld zum Aktualisieren angegeben' }; + } + + await quizzesStore.updateQuestion(questionId, diff); + return { + success: true, + data: { questionId, fields: Object.keys(diff) }, + message: 'Frage aktualisiert', + }; + }, + }, + { + name: 'delete_quiz_question', + module: 'quiz', + description: 'Löscht eine Frage aus einem Quiz', + parameters: [ + { name: 'questionId', type: 'string', description: 'ID der Frage', required: true }, + ], + async execute(params) { + const questionId = String(params.questionId ?? ''); + const existing = await readLocalQuestion(questionId); + if (!existing) return { success: false, message: `Frage ${questionId} nicht gefunden` }; + + const parentQuiz = await readLocalQuiz(existing.quizId); + await quizzesStore.deleteQuestion(questionId); + + return { + success: true, + data: { questionId, quizId: existing.quizId }, + message: parentQuiz ? `Frage aus "${parentQuiz.title}" entfernt` : 'Frage entfernt', + }; + }, + }, { name: 'list_quizzes', module: 'quiz', @@ -263,4 +434,90 @@ export const quizTools: ModuleTool[] = [ } }, }, + { + name: 'get_quiz_questions', + module: 'quiz', + description: + 'Liest alle Fragen eines Quiz (id, order, type, questionText, options, explanation). Nutze dies bevor du weitere Fragen ergänzt, um Duplikate zu vermeiden', + parameters: [{ name: 'quizId', type: 'string', description: 'ID des Quiz', required: true }], + async execute(params) { + const quizId = String(params.quizId ?? ''); + const existing = await readLocalQuiz(quizId); + if (!existing) return { success: false, message: `Quiz ${quizId} nicht gefunden` }; + + try { + const visible = ( + await db + .table('quizQuestions') + .where('quizId') + .equals(quizId) + .toArray() + ).filter((q) => !q.deletedAt); + const decrypted = await decryptRecords('quizQuestions', visible); + const questions = decrypted.map(toQuestion).sort((a, b) => a.order - b.order); + + return { + success: true, + data: { quizId, quizTitle: existing.title, questions, total: questions.length }, + message: `${questions.length} Frage(n) aus "${existing.title}" gelistet`, + }; + } catch (err) { + if (err instanceof VaultLockedError) { + return { + success: false, + message: 'Vault ist gesperrt — Fragen können nicht entschlüsselt werden', + }; + } + throw err; + } + }, + }, + { + name: 'get_quiz_stats', + module: 'quiz', + description: + 'Gibt Statistiken zu einem Quiz zurück: Anzahl der Versuche, Durchschnitts-Score, bester Score, letzter Versuch', + parameters: [{ name: 'quizId', type: 'string', description: 'ID des Quiz', required: true }], + async execute(params) { + const quizId = String(params.quizId ?? ''); + const existing = await readLocalQuiz(quizId); + if (!existing) return { success: false, message: `Quiz ${quizId} nicht gefunden` }; + + const all = await db + .table('quizAttempts') + .where('quizId') + .equals(quizId) + .toArray(); + const completed = all.filter((a) => !a.deletedAt && a.finishedAt !== null); + + const attemptCount = completed.length; + const avgScore = + attemptCount > 0 ? completed.reduce((sum, a) => sum + (a.score ?? 0), 0) / attemptCount : 0; + const bestScore = attemptCount > 0 ? Math.max(...completed.map((a) => a.score ?? 0)) : 0; + const lastAttemptAt = + attemptCount > 0 + ? completed + .map((a) => a.finishedAt as string) + .sort() + .reverse()[0] + : null; + + return { + success: true, + data: { + quizId, + quizTitle: existing.title, + questionCount: existing.questionCount ?? 0, + attemptCount, + avgScore: Number(avgScore.toFixed(3)), + bestScore: Number(bestScore.toFixed(3)), + lastAttemptAt, + }, + message: + attemptCount === 0 + ? `"${existing.title}" wurde noch nicht gespielt` + : `"${existing.title}" — ${attemptCount} Versuch(e), ⌀ ${Math.round(avgScore * 100)} %, beste ${Math.round(bestScore * 100)} %`, + }; + }, + }, ]; diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts index 11aae9764..7cd4f756d 100644 --- a/packages/shared-ai/src/tools/schemas.ts +++ b/packages/shared-ai/src/tools/schemas.ts @@ -974,6 +974,31 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ }, ], }, + { + name: 'update_quiz', + module: 'quiz', + description: + 'Aktualisiert Metadaten eines bestehenden Quiz. Nur die mitgegebenen Felder werden geschrieben. Leerstring bei description/category loescht den Wert', + defaultPolicy: 'propose', + parameters: [ + { name: 'quizId', type: 'string', description: 'ID des Quiz', required: true }, + { name: 'title', type: 'string', description: 'Neuer Titel', required: false }, + { name: 'description', type: 'string', description: 'Neue Beschreibung', required: false }, + { name: 'category', type: 'string', description: 'Neue Kategorie', required: false }, + { + name: 'isPinned', + type: 'boolean', + description: 'Quiz oben anpinnen', + required: false, + }, + { + name: 'isArchived', + type: 'boolean', + description: 'Quiz archivieren (aus Liste ausblenden)', + required: false, + }, + ], + }, { name: 'add_quiz_question', module: 'quiz', @@ -1004,6 +1029,50 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ }, ], }, + { + name: 'update_quiz_question', + module: 'quiz', + description: + 'Aktualisiert eine vorhandene Frage. Beim Aendern der Antworten muessen type + optionsJson zusammen uebergeben werden (gleiches Format wie bei add_quiz_question). Text und Erklaerung koennen unabhaengig geaendert werden', + defaultPolicy: 'propose', + parameters: [ + { name: 'questionId', type: 'string', description: 'ID der Frage', required: true }, + { + name: 'questionText', + type: 'string', + description: 'Neue Fragestellung', + required: false, + }, + { + name: 'type', + type: 'string', + description: 'Neuer Fragetyp (wenn optionsJson mitgegeben wird)', + required: false, + enum: ['single', 'multi', 'truefalse', 'text'], + }, + { + name: 'optionsJson', + type: 'string', + description: 'Neue Antwortdaten — Format abhaengig vom type', + required: false, + }, + { + name: 'explanation', + type: 'string', + description: 'Neue Erklaerung (Leerstring loescht)', + required: false, + }, + ], + }, + { + name: 'delete_quiz_question', + module: 'quiz', + description: 'Loescht eine Frage aus einem Quiz', + defaultPolicy: 'propose', + parameters: [ + { name: 'questionId', type: 'string', description: 'ID der Frage', required: true }, + ], + }, { name: 'list_quizzes', module: 'quiz', @@ -1025,6 +1094,22 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ }, ], }, + { + name: 'get_quiz_questions', + module: 'quiz', + description: + 'Liest alle Fragen eines Quiz (id, order, type, questionText, options, explanation). Nutze dies bevor du weitere Fragen ergaenzt, um Duplikate zu vermeiden', + defaultPolicy: 'auto', + parameters: [{ name: 'quizId', type: 'string', description: 'ID des Quiz', required: true }], + }, + { + name: 'get_quiz_stats', + module: 'quiz', + description: + 'Gibt Statistiken zu einem Quiz zurueck: Anzahl der Versuche, Durchschnitts-Score, bester Score, letzter Versuch. Nuetzlich fuer adaptive Missionen (Schwachstellen erkennen)', + defaultPolicy: 'auto', + parameters: [{ name: 'quizId', type: 'string', description: 'ID des Quiz', required: true }], + }, ]; // ═══════════════════════════════════════════════════════════════