mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
a295894ca6
commit
0d85d7c36b
3 changed files with 640 additions and 0 deletions
|
|
@ -50,6 +50,7 @@ import { websiteTools } from '$lib/modules/website/tools';
|
|||
import { writingTools } from '$lib/modules/writing/tools';
|
||||
import { comicTools } from '$lib/modules/comic/tools';
|
||||
import { augurTools } from '$lib/modules/augur/tools';
|
||||
import { formsTools } from '$lib/modules/forms/tools';
|
||||
|
||||
let initialized = false;
|
||||
|
||||
|
|
@ -101,5 +102,6 @@ export function initTools(): void {
|
|||
registerTools(writingTools);
|
||||
registerTools(comicTools);
|
||||
registerTools(augurTools);
|
||||
registerTools(formsTools);
|
||||
initialized = true;
|
||||
}
|
||||
|
|
|
|||
488
apps/mana/apps/web/src/lib/modules/forms/tools.ts
Normal file
488
apps/mana/apps/web/src/lib/modules/forms/tools.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -2494,6 +2494,156 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [
|
|||
defaultPolicy: 'auto',
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue