feat(ai): expand Quiz tools — edit/delete questions, edit meta, stats

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-19 19:50:24 +02:00
parent dd756c4664
commit 7fb31e41b5
3 changed files with 346 additions and 4 deletions

View file

@ -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`.

View file

@ -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<LocalQuiz | null> {
}
}
async function readLocalQuestion(id: string): Promise<LocalQuizQuestion | null> {
const local = await db.table<LocalQuizQuestion>('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<LocalQuiz, 'title' | 'description' | 'category' | 'isPinned' | 'isArchived'>
> = {};
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<LocalQuizQuestion, 'type' | 'questionText' | 'options' | 'explanation'>
> = {};
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<LocalQuizQuestion>('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<LocalQuizAttempt>('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)} %`,
};
},
},
];

View file

@ -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 }],
},
];
// ═══════════════════════════════════════════════════════════════