mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
dd756c4664
commit
7fb31e41b5
3 changed files with 346 additions and 4 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 (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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)} %`,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
},
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue