feat(forms): M5 AI tools — 7 tools im AI_TOOL_CATALOG

AI-Zugriff aufs Forms-Modul (docs/plans/forms-module.md M5):

Propose (User-Approval erforderlich):
- forms_create — neues Formular im Draft-Status, optional mit Feldern.
  Field-Shape im params-Array: { type, label, required?, helpText?,
  options?: [{label}] }. Type-Enum aus dem 11-Typ-Katalog. Planner
  kann z.B. "Vereins-Anmeldung" mit Name+Email+Position+Trikotgröße
  in einem Aufruf bauen.
- forms_add_field — Feld ans Ende anhängen, Reorder bleibt User
  vorbehalten (Drag im Builder).
- forms_publish — draft → published. Wirft, wenn Form keine Antwort-
  felder hat (nur section/consent würde Public-Submit sinnlos machen).
- forms_close — published → closed, Antworten + Share-Link bleiben.

Auto (silent execution während Planner-Reasoning):
- forms_list — Metadaten (id, title, status, fieldCount, responseCount,
  visibility), Status-Filter optional, Default-Limit 50. VaultLocked-
  aware → klare Fehlermeldung statt Crash.
- forms_get_responses — Aggregat-Stats: per Form ein
  ResponseAggregate {totalCount, statusCounts, choiceHistograms,
  textSamples, numericStats}. Choice-Felder mit Option-Label-Mapping
  (nicht Option-IDs), Text-Felder als Sample-Array (cap 50, default).
- forms_summarize_responses — gleicher Aggregator mit window-filter
  (sinceDays) und höherem Sample-Cap (200), als Daten-Vorlage für
  LLM-Clustering im nächsten Planner-Schritt. Augur-style: keine
  eigene LLM-Roundtrip, der Planner formuliert Themes selbst.

Verdrahtung:
- AI_TOOL_CATALOG in @mana/shared-ai mit 7 ToolSchema-Einträgen +
  defaultPolicy.
- ModuleTool-Implementierungen in modules/forms/tools.ts mit
  scopedForModule für Space-Awareness, decryptRecords für encrypted-
  table-Reads, VaultLocked-Handling.
- Registriert in data/tools/init.ts.

Validierung:
- mana-ai planner-drift test: 4/4 grün — alle 4 propose-Tools
  (forms_create/add_field/publish/close) im SERVER_TOOLS-Subset.
- svelte-check 0 errors in forms/.
- Forms unit tests: 16/16 (csv + branching) unverändert grün.

Tools-executor.test.ts ist pre-existing rot wegen
$lib/modules/context-Drift in module-registry.ts (Parallel-Session-
WIP, nicht durch mich).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-29 00:33:55 +02:00
parent a295894ca6
commit 0d85d7c36b
3 changed files with 640 additions and 0 deletions

View file

@ -50,6 +50,7 @@ import { websiteTools } from '$lib/modules/website/tools';
import { writingTools } from '$lib/modules/writing/tools'; import { writingTools } from '$lib/modules/writing/tools';
import { comicTools } from '$lib/modules/comic/tools'; import { comicTools } from '$lib/modules/comic/tools';
import { augurTools } from '$lib/modules/augur/tools'; import { augurTools } from '$lib/modules/augur/tools';
import { formsTools } from '$lib/modules/forms/tools';
let initialized = false; let initialized = false;
@ -101,5 +102,6 @@ export function initTools(): void {
registerTools(writingTools); registerTools(writingTools);
registerTools(comicTools); registerTools(comicTools);
registerTools(augurTools); registerTools(augurTools);
registerTools(formsTools);
initialized = true; initialized = true;
} }

View file

@ -0,0 +1,488 @@
/**
* Forms tools AI-accessible CRUD + aggregation for the forms module.
* Plan: docs/plans/forms-module.md M5.
*
* Propose:
* - forms_create new form (draft)
* - forms_add_field append a field to an existing form
* - forms_publish draft published
* - forms_close published closed
*
* Auto:
* - forms_list metadata of forms in the active Space
* - forms_get_responses aggregate stats for one form
* - forms_summarize_responses raw text answers + choice histograms
* (no LLM roundtrip, planner clusters)
*/
import type { ModuleTool } from '$lib/data/tools/types';
import { formsStore } from './stores/forms.svelte';
import { formTable, formResponseTable } from './collections';
import { decryptRecords, VaultLockedError } from '$lib/data/crypto';
import { toForm, toFormResponse } from './queries';
import { scopedForModule } from '$lib/data/scope';
import type {
AnswerValue,
FieldOption,
FieldType,
FormField,
FormStatus,
LocalForm,
LocalFormResponse,
} from './types';
const FIELD_TYPES: readonly FieldType[] = [
'short_text',
'long_text',
'single_choice',
'multi_choice',
'number',
'date',
'email',
'yes_no',
'rating',
'section',
'consent',
];
function asTrimmedString(raw: unknown): string {
return typeof raw === 'string' ? raw.trim() : '';
}
function asEnum<T extends string>(raw: unknown, allowed: readonly T[]): T | undefined {
if (typeof raw !== 'string') return undefined;
return (allowed as readonly string[]).includes(raw) ? (raw as T) : undefined;
}
function buildFieldFromShape(raw: unknown): FormField | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Record<string, unknown>;
const type = asEnum<FieldType>(obj.type, FIELD_TYPES);
const label = asTrimmedString(obj.label);
if (!type || !label) return null;
const required = obj.required === true;
const helpText = asTrimmedString(obj.helpText) || undefined;
const field: FormField = { id: crypto.randomUUID(), type, label, required };
if (helpText) field.helpText = helpText;
if ((type === 'single_choice' || type === 'multi_choice') && Array.isArray(obj.options)) {
const opts: FieldOption[] = [];
for (const o of obj.options) {
if (!o || typeof o !== 'object') continue;
const optLabel = asTrimmedString((o as Record<string, unknown>).label);
if (optLabel) opts.push({ id: crypto.randomUUID(), label: optLabel });
}
if (opts.length > 0) field.options = opts;
}
if (type === 'rating') {
field.config = { ratingScale: 5 };
}
return field;
}
export const formsTools: ModuleTool[] = [
{
name: 'forms_create',
module: 'forms',
description: 'Erstellt ein neues Formular im Draft-Status',
parameters: [
{ name: 'title', type: 'string', description: 'Titel', required: true },
{ name: 'description', type: 'string', description: 'Beschreibung', required: false },
{ name: 'fields', type: 'array', description: 'Optionale Felder', required: false },
],
async execute(params) {
const title = asTrimmedString(params.title);
if (!title) return { success: false, message: 'title darf nicht leer sein' };
const description = asTrimmedString(params.description) || null;
const fields: FormField[] = [];
if (Array.isArray(params.fields)) {
for (const raw of params.fields) {
const field = buildFieldFromShape(raw);
if (field) fields.push(field);
}
}
const form = await formsStore.createForm({
title,
description,
fields,
});
return {
success: true,
data: { formId: form.id, title: form.title, fieldCount: form.fields.length },
message: `Formular "${title}" angelegt (${fields.length} Felder)`,
};
},
},
{
name: 'forms_add_field',
module: 'forms',
description: 'Fügt einem Formular ein Feld hinzu',
parameters: [
{ name: 'formId', type: 'string', description: 'ID', required: true },
{ name: 'type', type: 'string', description: 'Feldtyp', required: true },
{ name: 'label', type: 'string', description: 'Label', required: true },
{ name: 'helpText', type: 'string', description: 'Hilfetext', required: false },
{ name: 'required', type: 'boolean', description: 'Pflichtfeld', required: false },
{
name: 'options',
type: 'array',
description: 'Optionen für choice-Felder',
required: false,
},
],
async execute(params) {
const formId = asTrimmedString(params.formId);
if (!formId) return { success: false, message: 'formId darf nicht leer sein' };
const existing = await formTable.get(formId);
if (!existing || existing.deletedAt) {
return { success: false, message: `Formular ${formId} nicht gefunden` };
}
const field = buildFieldFromShape(params);
if (!field) {
return {
success: false,
message: 'Feld konnte nicht gebaut werden — type oder label fehlt/ungültig',
};
}
await formsStore.addField(formId, field);
return {
success: true,
data: { formId, fieldId: field.id, type: field.type, label: field.label },
message: `Feld "${field.label}" hinzugefügt`,
};
},
},
{
name: 'forms_publish',
module: 'forms',
description: 'Setzt ein Formular auf "published"',
parameters: [{ name: 'formId', type: 'string', description: 'ID', required: true }],
async execute(params) {
const formId = asTrimmedString(params.formId);
if (!formId) return { success: false, message: 'formId darf nicht leer sein' };
const existing = await formTable.get(formId);
if (!existing || existing.deletedAt) {
return { success: false, message: `Formular ${formId} nicht gefunden` };
}
const answerFields = (existing.fields ?? []).filter(
(f) => f.type !== 'section' && f.type !== 'consent'
);
if (answerFields.length === 0) {
return {
success: false,
message: 'Formular braucht mindestens ein Antwortfeld vor dem Veröffentlichen',
};
}
await formsStore.setStatus(formId, 'published');
return {
success: true,
data: { formId, status: 'published' },
message: 'Formular veröffentlicht',
};
},
},
{
name: 'forms_close',
module: 'forms',
description: 'Setzt ein Formular auf "closed"',
parameters: [{ name: 'formId', type: 'string', description: 'ID', required: true }],
async execute(params) {
const formId = asTrimmedString(params.formId);
if (!formId) return { success: false, message: 'formId darf nicht leer sein' };
const existing = await formTable.get(formId);
if (!existing || existing.deletedAt) {
return { success: false, message: `Formular ${formId} nicht gefunden` };
}
await formsStore.setStatus(formId, 'closed');
return {
success: true,
data: { formId, status: 'closed' },
message: 'Formular geschlossen',
};
},
},
{
name: 'forms_list',
module: 'forms',
description: 'Listet Formulare im aktiven Space',
parameters: [
{ name: 'status', type: 'string', description: 'Status-Filter', required: false },
{ name: 'limit', type: 'number', description: 'Max. Anzahl', required: false },
],
async execute(params) {
const status = asEnum<FormStatus>(params.status, ['draft', 'published', 'closed']);
const limit = typeof params.limit === 'number' ? Math.max(1, params.limit) : 50;
let items = (await scopedForModule<LocalForm, string>('forms', 'forms').toArray()).filter(
(f) => !f.deletedAt
);
if (status) items = items.filter((f) => f.status === status);
try {
const decrypted = await decryptRecords('forms', items);
const forms = decrypted.map(toForm).slice(0, limit);
return {
success: true,
data: forms.map((f) => ({
id: f.id,
title: f.title,
status: f.status,
fieldCount: f.fields.length,
responseCount: f.responseCount,
visibility: f.visibility,
})),
message: `${forms.length} Formular(e) gefunden`,
};
} catch (err) {
if (err instanceof VaultLockedError) {
return {
success: false,
message: 'Vault ist verschlossen — Formulare können nicht entschlüsselt werden',
};
}
throw err;
}
},
},
{
name: 'forms_get_responses',
module: 'forms',
description: 'Aggregat-Stats über die Antworten eines Formulars',
parameters: [
{ name: 'formId', type: 'string', description: 'ID', required: true },
{
name: 'limit',
type: 'number',
description: 'Max. Text-Antworten pro Feld',
required: false,
},
],
async execute(params) {
const formId = asTrimmedString(params.formId);
if (!formId) return { success: false, message: 'formId darf nicht leer sein' };
const limit = typeof params.limit === 'number' ? Math.max(1, params.limit) : 50;
const formRow = await formTable.get(formId);
if (!formRow || formRow.deletedAt) {
return { success: false, message: `Formular ${formId} nicht gefunden` };
}
try {
const [decryptedForm] = await decryptRecords('forms', [formRow]);
const form = toForm(decryptedForm);
const responseRows = (
await scopedForModule<LocalFormResponse, string>('forms', 'formResponses').toArray()
).filter((r) => !r.deletedAt && r.formId === formId);
const decrypted = await decryptRecords('formResponses', responseRows);
const responses = decrypted.map(toFormResponse);
return {
success: true,
data: aggregateResponses(form.fields, responses, limit),
message: `${responses.length} Antwort(en) aggregiert`,
};
} catch (err) {
if (err instanceof VaultLockedError) {
return {
success: false,
message: 'Vault ist verschlossen — Antworten können nicht entschlüsselt werden',
};
}
throw err;
}
},
},
{
name: 'forms_summarize_responses',
module: 'forms',
description: 'Rohdaten + Histogramme für LLM-Clustering im nächsten Planner-Schritt',
parameters: [
{ name: 'formId', type: 'string', description: 'ID', required: true },
{ name: 'sinceDays', type: 'number', description: 'Nur letzte N Tage', required: false },
],
async execute(params) {
const formId = asTrimmedString(params.formId);
if (!formId) return { success: false, message: 'formId darf nicht leer sein' };
const formRow = await formTable.get(formId);
if (!formRow || formRow.deletedAt) {
return { success: false, message: `Formular ${formId} nicht gefunden` };
}
const sinceDays = typeof params.sinceDays === 'number' ? params.sinceDays : null;
const sinceIso =
sinceDays !== null
? new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1000).toISOString()
: null;
try {
const [decryptedForm] = await decryptRecords('forms', [formRow]);
const form = toForm(decryptedForm);
const responseRows = (
await scopedForModule<LocalFormResponse, string>('forms', 'formResponses').toArray()
).filter((r) => {
if (r.deletedAt || r.formId !== formId) return false;
if (sinceIso && r.submittedAt < sinceIso) return false;
return true;
});
const decrypted = await decryptRecords('formResponses', responseRows);
const responses = decrypted.map(toFormResponse);
return {
success: true,
data: {
formTitle: form.title,
windowStart: sinceIso,
windowDays: sinceDays,
...aggregateResponses(form.fields, responses, 200),
},
message: `${responses.length} Antwort(en) für Clustering bereit`,
};
} catch (err) {
if (err instanceof VaultLockedError) {
return {
success: false,
message: 'Vault ist verschlossen',
};
}
throw err;
}
},
},
];
// ─── Aggregation Helper ─────────────────────────────────────
interface FieldHistogram {
fieldId: string;
label: string;
type: FieldType;
histogram: Record<string, number>;
}
interface FieldTextSamples {
fieldId: string;
label: string;
type: FieldType;
samples: string[];
totalCount: number;
}
interface ResponseAggregate {
totalCount: number;
statusCounts: Record<string, number>;
choiceHistograms: FieldHistogram[];
textSamples: FieldTextSamples[];
numericStats: {
fieldId: string;
label: string;
min: number;
max: number;
avg: number;
count: number;
}[];
}
function aggregateResponses(
fields: FormField[],
responses: { status: string; answers: Record<string, AnswerValue> }[],
textLimit: number
): ResponseAggregate {
const statusCounts: Record<string, number> = {};
for (const r of responses) {
statusCounts[r.status] = (statusCounts[r.status] ?? 0) + 1;
}
const choiceHistograms: FieldHistogram[] = [];
const textSamples: FieldTextSamples[] = [];
const numericStats: ResponseAggregate['numericStats'] = [];
for (const field of fields) {
if (field.type === 'section' || field.type === 'consent') continue;
if (field.type === 'single_choice' || field.type === 'multi_choice') {
const optLabelById = new Map((field.options ?? []).map((o) => [o.id, o.label]));
const histogram: Record<string, number> = {};
for (const r of responses) {
const v = r.answers[field.id];
if (Array.isArray(v)) {
for (const optId of v) {
const key = optLabelById.get(String(optId)) ?? String(optId);
histogram[key] = (histogram[key] ?? 0) + 1;
}
} else if (typeof v === 'string') {
const key = optLabelById.get(v) ?? v;
histogram[key] = (histogram[key] ?? 0) + 1;
}
}
choiceHistograms.push({ fieldId: field.id, label: field.label, type: field.type, histogram });
} else if (field.type === 'yes_no') {
const histogram: Record<string, number> = {};
for (const r of responses) {
const v = r.answers[field.id];
if (typeof v === 'boolean') {
const key = v ? 'yes' : 'no';
histogram[key] = (histogram[key] ?? 0) + 1;
}
}
choiceHistograms.push({ fieldId: field.id, label: field.label, type: field.type, histogram });
} else if (field.type === 'rating' || field.type === 'number') {
const values: number[] = [];
for (const r of responses) {
const v = r.answers[field.id];
if (typeof v === 'number') values.push(v);
}
if (values.length > 0) {
const sum = values.reduce((a, b) => a + b, 0);
numericStats.push({
fieldId: field.id,
label: field.label,
min: Math.min(...values),
max: Math.max(...values),
avg: sum / values.length,
count: values.length,
});
}
} else {
// short_text, long_text, email, date — collect samples verbatim
const samples: string[] = [];
let total = 0;
for (const r of responses) {
const v = r.answers[field.id];
if (typeof v === 'string' && v.trim().length > 0) {
total += 1;
if (samples.length < textLimit) samples.push(v);
}
}
textSamples.push({
fieldId: field.id,
label: field.label,
type: field.type,
samples,
totalCount: total,
});
}
}
return {
totalCount: responses.length,
statusCounts,
choiceHistograms,
textSamples,
numericStats,
};
}

View file

@ -2494,6 +2494,156 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
defaultPolicy: 'auto', defaultPolicy: 'auto',
parameters: [], parameters: [],
}, },
// ── Forms ───────────────────────────────────────────────────
// Eigenes Modul fuer Typeform-aehnliche Formulare. Plan:
// docs/plans/forms-module.md M5.
{
name: 'forms_create',
module: 'forms',
description:
'Legt ein neues Formular an. Status ist immer "draft" (publishen via forms_publish). Felder optional als Array — der Planner kann z.B. fuer eine Vereins-Anmeldung mehrere short_text + email + consent Felder auf einmal vorschlagen.',
defaultPolicy: 'propose',
parameters: [
{
name: 'title',
type: 'string',
description: 'Titel des Formulars (z.B. "Anmeldung Sommerfest")',
required: true,
},
{
name: 'description',
type: 'string',
description: 'Optionaler Beschreibungstext oben im Formular',
required: false,
},
{
name: 'fields',
type: 'array',
description:
'Optionales Array von Feld-Definitionen. Jedes Feld: { type, label, required?, helpText?, options?: [{label}] }. Erlaubte type-Werte: short_text | long_text | single_choice | multi_choice | number | date | email | yes_no | rating | section | consent.',
required: false,
},
],
},
{
name: 'forms_add_field',
module: 'forms',
description:
'Fuegt einem bestehenden Formular ein einzelnes Feld hinzu. Ans Ende der Feldliste angehaengt — Reorder ist nicht ueber dieses Tool moeglich (User macht das per Drag im Builder).',
defaultPolicy: 'propose',
parameters: [
{ name: 'formId', type: 'string', description: 'ID des Formulars', required: true },
{
name: 'type',
type: 'string',
description: 'Feldtyp',
required: true,
enum: [
'short_text',
'long_text',
'single_choice',
'multi_choice',
'number',
'date',
'email',
'yes_no',
'rating',
'section',
'consent',
],
},
{ name: 'label', type: 'string', description: 'Label / Frage des Feldes', required: true },
{ name: 'helpText', type: 'string', description: 'Hilfetext (optional)', required: false },
{
name: 'required',
type: 'boolean',
description: 'Pflichtfeld (Standard false)',
required: false,
},
{
name: 'options',
type: 'array',
description:
'Bei single_choice / multi_choice: Array von { label: string } — IDs werden generiert.',
required: false,
},
],
},
{
name: 'forms_publish',
module: 'forms',
description:
'Bewegt ein Formular von "draft" auf "published". Erst nach diesem Schritt kann der User die Sichtbarkeit auf "unlisted" setzen und einen Share-Link erzeugen. Wirft, wenn das Formular keine Antwortfelder hat (nur section/consent).',
defaultPolicy: 'propose',
parameters: [
{ name: 'formId', type: 'string', description: 'ID des Formulars', required: true },
],
},
{
name: 'forms_close',
module: 'forms',
description:
'Setzt ein veroeffentlichtes Formular auf "closed" — keine neuen Antworten mehr. Existierende Antworten und der Share-Link bleiben erhalten; das Formular wird aber im SharedFormView nicht mehr submitbar gerendert.',
defaultPolicy: 'propose',
parameters: [
{ name: 'formId', type: 'string', description: 'ID des Formulars', required: true },
],
},
{
name: 'forms_list',
module: 'forms',
description:
'Listet Formulare im aktiven Space (id, title, status, fieldCount, responseCount, visibility). Optional nach Status filterbar.',
defaultPolicy: 'auto',
parameters: [
{
name: 'status',
type: 'string',
description: 'Nur einen Status zeigen',
required: false,
enum: ['draft', 'published', 'closed'],
},
{
name: 'limit',
type: 'number',
description: 'Maximale Anzahl (Standard 50)',
required: false,
},
],
},
{
name: 'forms_get_responses',
module: 'forms',
description:
'Liefert Aggregate ueber die Antworten eines Formulars: responseCount pro Status, pro Choice-Feld eine Histogramm-Map (option-label → count), pro Text-Feld eine Liste der ersten N Antworten. Antworten werden client-side entschluesselt; vault-locked → leerer Antwort-Vector.',
defaultPolicy: 'auto',
parameters: [
{ name: 'formId', type: 'string', description: 'ID des Formulars', required: true },
{
name: 'limit',
type: 'number',
description: 'Max. Text-Antworten pro Feld (Standard 50)',
required: false,
},
],
},
{
name: 'forms_summarize_responses',
module: 'forms',
description:
'Sammelt rohe Text-Antworten + Choice-Histogramme eines Formulars und gibt sie als strukturierte Payload zurueck, damit der naechste Planner-Schritt thematisch clustern kann (Augur-style). Reine Daten-Extraktion — keine eigene LLM-Roundtrip. Ideal in einer Mission "Fasse die Pulse-Check-Antworten der Woche zusammen".',
defaultPolicy: 'auto',
parameters: [
{ name: 'formId', type: 'string', description: 'ID des Formulars', required: true },
{
name: 'sinceDays',
type: 'number',
description: 'Nur Antworten der letzten N Tage einbeziehen (Standard: alle)',
required: false,
},
],
},
]; ];
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════