mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:41:09 +02:00
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:
parent
4b8defcc4a
commit
2bcc3954ea
4 changed files with 344 additions and 1 deletions
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
266
apps/mana/apps/web/src/lib/modules/quiz/tools.ts
Normal file
266
apps/mana/apps/web/src/lib/modules/quiz/tools.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue