feat(ai): add Quiz tools (create_quiz, add_quiz_question, list_quizzes)

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-19 19:29:35 +02:00
parent 4b8defcc4a
commit 2bcc3954ea
4 changed files with 344 additions and 1 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 (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`.

View file

@ -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;
}

View file

@ -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<string, unknown>;
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<LocalQuiz | null> {
const local = await db.table<LocalQuiz>('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<LocalQuiz>('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;
}
},
},
];

View file

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