From 48ac44e39c4d23c40ed807a902396b18561b0ee6 Mon Sep 17 00:00:00 2001 From: xziy Date: Sat, 27 Sep 2025 12:45:31 +0700 Subject: [PATCH] Enable fixture agent record creation --- HISTORY.md | 5 + docs/AiAssistant.md | 25 ++++ fixture/adminizerConfig.ts | 4 +- fixture/helpers/ai/OpenAiDataAgentService.ts | 130 ++++++++++++++++++- fixture/helpers/seedDatabase.ts | 6 +- fixture/index.ts | 6 +- src/lib/DataAccessor.ts | 3 +- 7 files changed, 165 insertions(+), 14 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 2c808728..39f0ce33 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,8 @@ +## 4.3.8 +- Added a `mutate_model_record` tool so the fixture OpenAI agent can create records through `DataAccessor` with the caller's permissions. +- Limited the fixture AI assistant to the OpenAI data agent and updated seeded access rights for the new token. +- Documented the JSON payload format for agent-driven mutations. + ## 4.3.7 - Added `DataAccessor.describeAccessibleFields()` to expose per-action field metadata for AI and form builders. - Extended the fixture OpenAI agent with schema introspection, payload sanitisation, and required-field validation when creating records. diff --git a/docs/AiAssistant.md b/docs/AiAssistant.md index c045d6d8..181543ae 100644 --- a/docs/AiAssistant.md +++ b/docs/AiAssistant.md @@ -100,9 +100,34 @@ OpenAI's Agents API while still relying on Adminizer's abstractions: `AbstractAiModelService`. * Database reads are performed through `DataAccessor`, which means the usual access control and field sanitisation rules are enforced automatically. +* Record creation is also performed through the same accessor using the `mutate_model_record` + tool. The agent builds a JSON payload with the caller's permissions, validates required fields, + and persists the record on behalf of the user. * Conversation history is converted into the `@openai/agents` protocol so follow-up questions can build on previous answers. +### Creating Records Through the Agent + +When the user requests a new record the agent calls the `mutate_model_record` tool with a payload +similar to the following: + +```json +{ + "action": "create", + "model": "Example", + "payload": { + "title": "Launch checklist", + "description": "Kick-off tasks captured by the agent", + "sort": true + } +} +``` + +The payload is sanitised against the current user's `DataAccessor` permissions, so any disabled or +restricted fields are ignored automatically. Required fields are validated before the mutation runs +and a descriptive error is returned to the chat if anything is missing. Once the tool succeeds the +assistant summarises the action and returns the created record identifier to the user. + To enable the agent locally, set the following environment variables before starting the fixture: ```bash diff --git a/fixture/adminizerConfig.ts b/fixture/adminizerConfig.ts index 57ca670b..40fcf95a 100644 --- a/fixture/adminizerConfig.ts +++ b/fixture/adminizerConfig.ts @@ -437,8 +437,8 @@ const config: AdminpanelConfig = { }, aiAssistant: { enabled: true, - defaultModel: 'openai', - models: ['openai'], + defaultModel: 'openai-data', + models: ['openai-data'], }, routePrefix: routePrefix, // routePrefix: "/admin", diff --git a/fixture/helpers/ai/OpenAiDataAgentService.ts b/fixture/helpers/ai/OpenAiDataAgentService.ts index 321744f7..30d6c1fe 100644 --- a/fixture/helpers/ai/OpenAiDataAgentService.ts +++ b/fixture/helpers/ai/OpenAiDataAgentService.ts @@ -92,7 +92,7 @@ export class OpenAiDataAgentService extends AbstractAiModelService { }, fields: { type: 'array', - items: { type: 'string', minLength: 1 }, + items: {type: 'string', minLength: 1}, description: 'Optional list of fields to include in the response' }, limit: { @@ -102,7 +102,7 @@ export class OpenAiDataAgentService extends AbstractAiModelService { description: 'Maximum number of records to return (default 10).' } }, - required: ['model', 'filter', 'fields', 'limit'], + required: ['model'], additionalProperties: false }, execute: async (input: any, runContext?: RunContext) => { @@ -140,19 +140,78 @@ export class OpenAiDataAgentService extends AbstractAiModelService { }, }); + const mutateRecordTool = tool({ + name: 'mutate_model_record', + description: 'Create Adminizer records using DataAccessor with the caller\'s permissions.', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Mutation action to perform (currently only "create" is supported).', + enum: ['create'], + default: 'create', + }, + model: { + type: 'string', + description: 'Model name as defined in the Adminizer configuration.', + minLength: 1, + }, + payload: { + description: 'Record fields as an object or JSON string.', + oneOf: [ + {type: 'object'}, + {type: 'string'}, + ], + }, + }, + required: ['model', 'payload'], + additionalProperties: false, + }, + execute: async (input: any, runContext?: RunContext) => { + const activeUser = runContext?.context?.user ?? user; + + if (!input.model) { + throw new Error('Model name is required'); + } + + const entity = this.resolveEntity(input.model); + if (!entity.model) { + throw new Error(`Model "${input.model}" is not registered in Adminizer.`); + } + + const action = (input.action ?? 'create') as 'create'; + if (action !== 'create') { + throw new Error(`Unsupported mutation action: ${action}`); + } + + const accessor = new DataAccessor(this.adminizer, activeUser, entity, 'add'); + const sanitizedPayload = await this.prepareCreatePayload(input.payload, accessor); + + const created = await entity.model.create(sanitizedPayload, accessor); + + return JSON.stringify({ + model: entity.name, + action, + record: created, + }, null, 2); + }, + }); + return new Agent({ name: 'Adminizer data agent', instructions: [ 'You are an assistant that answers questions using Adminizer data.', - 'Always rely on the provided tool to inspect database records.', - 'Only include fields that are relevant to the question.', - 'Summaries should explain how the answer was derived from the data.', + 'Always rely on the provided tools to inspect or modify database records.', + 'Prefer concise JSON outputs for tool calls and provide human-readable summaries afterwards.', + 'Only include fields that are relevant to the request and double-check required values before creating records.', + 'Summaries should explain how the answer was derived from the data or confirm the performed mutation.', '', 'Accessible models:', modelSummary, ].join('\n'), handoffDescription: 'Retrieves Adminizer records using DataAccessor with full permission checks.', - tools: [dataQueryTool], + tools: [dataQueryTool, mutateRecordTool], model: this.model, }); } @@ -166,6 +225,65 @@ export class OpenAiDataAgentService extends AbstractAiModelService { }, {}); } + private async prepareCreatePayload( + rawPayload: unknown, + accessor: DataAccessor, + ): Promise> { + const parsedPayload = this.normalizePayload(rawPayload); + const fieldsConfig = accessor.getFieldsConfig(); + + if (!fieldsConfig) { + throw new Error('You do not have permission to create records for this model.'); + } + + const allowedKeys = Object.keys(fieldsConfig); + const sanitized: Record = {}; + + for (const key of allowedKeys) { + if (Object.prototype.hasOwnProperty.call(parsedPayload, key)) { + sanitized[key] = parsedPayload[key]; + } + } + + const missingRequired = Object.entries(fieldsConfig) + .filter(([_, config]) => Boolean(config?.config?.required)) + .map(([key]) => key) + .filter((key) => { + const value = sanitized[key]; + return value === undefined || value === null || value === ''; + }); + + if (missingRequired.length > 0) { + throw new Error(`Missing required fields: ${missingRequired.join(', ')}`); + } + + return sanitized; + } + + private normalizePayload(rawPayload: unknown): Record { + if (typeof rawPayload === 'string') { + try { + const parsed = JSON.parse(rawPayload); + return this.ensureRecord(parsed); + } catch { + throw new Error('Payload must be a valid JSON object string.'); + } + } + + return this.ensureRecord(rawPayload); + } + + private ensureRecord(value: unknown): Record { + const schema = z.record(z.any()); + const parsed = schema.safeParse(value); + + if (!parsed.success || Array.isArray(parsed.data)) { + throw new Error('Payload must be an object with field values.'); + } + + return parsed.data; + } + private toAgentInput(history: AiAssistantMessage[]): AgentInputItem[] { return history.map((message) => { if (message.role === 'user') { diff --git a/fixture/helpers/seedDatabase.ts b/fixture/helpers/seedDatabase.ts index 1d5c1924..8e57e70a 100644 --- a/fixture/helpers/seedDatabase.ts +++ b/fixture/helpers/seedDatabase.ts @@ -28,8 +28,7 @@ export async function seedDatabase( // ------------------ Groups ------------------ // const groupNames = [ { name: 'Admins', description: 'System administrators', tokens: [ - 'ai-assistant-dummy', - 'ai-assistant-openai', + 'ai-assistant-openai-data', ] }, { name: 'Users', description: 'Registered users', tokens: [ @@ -43,8 +42,7 @@ export async function seedDatabase( "update-example-model", "read-jsonschema-model", - "ai-assistant-dummy", - "ai-assistant-openai", + "ai-assistant-openai-data", ] }, { name: 'Guests', description: 'Guest access' }, diff --git a/fixture/index.ts b/fixture/index.ts index e9c6e7be..7cd14e90 100644 --- a/fixture/index.ts +++ b/fixture/index.ts @@ -16,6 +16,7 @@ import adminpanelConfig from "./adminizerConfig"; import {AdminpanelConfig} from "../dist/interfaces/adminpanelConfig"; import {sendNotificationsWithDelay} from "./helpers/notifications"; import {OpenAiDataAgentService} from "./helpers/ai/OpenAiDataAgentService"; +import {AiAssistantHandler} from "../dist/lib/ai-assistant/AiAssistantHandler"; import {ReactQuill} from "../modules/controls/wysiwyg/ReactQuill"; @@ -181,9 +182,12 @@ async function ormSharedFixtureLift(adminizer: Adminizer) { if (adminizer.config.aiAssistant?.enabled) { const openAiAgent = new OpenAiDataAgentService(adminizer); if (openAiAgent.isEnabled()) { + // Re-create the handler so only the data agent is exposed in the fixture. + adminizer.aiAssistantHandler = new AiAssistantHandler(adminizer); adminizer.aiAssistantHandler.registerModel(openAiAgent); - // Set as default model + // Reflect the active model in the runtime configuration so the UI shows a single option. + adminizer.config.aiAssistant.models = [openAiAgent.id]; adminizer.config.aiAssistant.defaultModel = openAiAgent.id; console.log(`[fixture] OpenAI data agent successfully registered with ID: ${openAiAgent.id}`); diff --git a/src/lib/DataAccessor.ts b/src/lib/DataAccessor.ts index 266f0c1f..4e6cacc3 100644 --- a/src/lib/DataAccessor.ts +++ b/src/lib/DataAccessor.ts @@ -440,7 +440,8 @@ export class DataAccessor { return null; } - const targetModel = (field.model?.model ?? field.model?.collection ?? field.model?.ref) as string | undefined; + const modelReference = field.model as {model?: string; collection?: string; ref?: string} | undefined; + const targetModel = (modelReference?.model ?? modelReference?.collection ?? modelReference?.ref) as string | undefined; if (!targetModel) { return null;