diff --git a/HISTORY.md b/HISTORY.md index 2c808728..7584ba6e 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,8 @@ +## 4.3.8 +- Added a `create_model_record` OpenAI tool that persists records through `DataAccessor` with the caller's write permissions. +- Relaxed the query tool schema to allow optional filters and documented the JSON command workflow. +- Updated the fixture configuration to expose only the `openai-data` assistant model by default. + ## 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..c8343f41 100644 --- a/docs/AiAssistant.md +++ b/docs/AiAssistant.md @@ -25,14 +25,26 @@ dependencies. ## Fixture OpenAI Agent -The fixture now also registers an `openai` model that executes structured commands against the database. The agent expects JSON instructions and uses `DataAccessor` under the hood, so every operation is filtered by the requesting user's permissions. +The fixture ships with a single OpenAI-backed model named `openai-data`. It demonstrates how to expose the Adminizer data layer to the assistant while respecting the currently authenticated user's permissions. Every tool call is routed through `DataAccessor`, so the familiar read/write checks and automatic field sanitisation remain in place. -Example payload for creating a record: +### Supported JSON commands + +The agent understands explicit JSON instructions and translates them into OpenAI tool invocations. The following payloads are handled out of the box: ```json { - "action": "create", - "entity": "Example", + "action": "query_model_records", + "model": "Example", + "filter": "{\"title\":{\"contains\":\"Hello\"}}", + "fields": ["id", "title"], + "limit": 5 +} +``` + +```json +{ + "action": "create_model_record", + "model": "Example", "data": { "title": "Hello from the assistant", "description": "Generated through the OpenAI agent" @@ -40,26 +52,11 @@ Example payload for creating a record: } ``` -If the user lacks the required access token (for example, `create-example-model`), the agent responds with an authorization error instead of touching the database. The `openai` fixture user (`login: openai`, `password: openai`) belongs to the administrators group, granting full access for experimentation. Regular users can be granted permissions by assigning the `ai-assistant-openai` token to their groups. - -### Discovering available fields +When the `data` property is provided as a string, the agent attempts to parse it as JSON before issuing the command. If the user does not have the required access token (for example, `add-example-model`), the tool raises an authorization error and no records are modified. -Before issuing a `create` command the agent can now describe the exact payload shape that is accepted for the chosen model. This -is achieved through the `fields` action which asks `DataAccessor` for the list of writable fields, their types, requirements, and -association hints. The agent trims any values that are not allowed and will stop execution if mandatory properties are missing. - -Example request for the schema: - -```json -{ - "action": "fields", - "entity": "Example" -} -``` +### Fixture credentials and permissions -The response enumerates each accessible field, including required flags, optional descriptions (taken from field tooltips), -allowed enums, and association targets. When a `create` command is executed afterwards the agent automatically reuses this -metadata to validate the payload and report missing values instead of failing with a generic database error. +The fixture user `openai` (`password: openai`) belongs to the administrators group and therefore has full access to the registered models. Custom users can be onboarded by granting them the `ai-assistant-openai-data` access token. ## Backend Overview 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..736d71aa 100644 --- a/fixture/helpers/ai/OpenAiDataAgentService.ts +++ b/fixture/helpers/ai/OpenAiDataAgentService.ts @@ -1,4 +1,3 @@ -import {z} from 'zod'; import { Agent, AgentInputItem, @@ -52,6 +51,16 @@ export class OpenAiDataAgentService extends AbstractAiModelService { return 'The OpenAI data agent is not configured. Please set the OPENAI_API_KEY environment variable.'; } + const directCommand = this.tryParseDirectCommand(prompt); + if (directCommand) { + try { + return await this.executeDirectCommand(directCommand, user); + } catch (error) { + Adminizer.log.error('[OpenAiDataAgentService] Direct command failed', error); + return 'Failed to execute the provided JSON command. Please verify the payload and try again.'; + } + } + try { const agent = this.createAgent(user); const conversation = this.toAgentInput(history); @@ -102,41 +111,40 @@ 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) => { 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.`); - } + return this.executeQueryModelRecords(input, activeUser); + }, + }); - const accessor = new DataAccessor(this.adminizer, activeUser, entity, 'list'); - let criteria = {}; - if (input.filter && input.filter.trim()) { - try { - criteria = JSON.parse(input.filter); - } catch (e) { - throw new Error('Invalid filter JSON'); - } - } - const records = await entity.model.find(criteria, accessor); - const limited = records.slice(0, input.limit ?? 10); - const projected = input.fields && input.fields.length > 0 - ? limited.map((record) => this.pickFields(record, input.fields ?? [])) - : limited; - - return JSON.stringify({ - model: entity.name, - count: projected.length, - records: projected, - }, null, 2); + const createRecordTool = tool({ + name: 'create_model_record', + description: 'Create a new Adminizer record using DataAccessor with the current user permissions.', + parameters: { + type: 'object', + properties: { + model: { + type: 'string', + description: 'Model name as defined in the Adminizer configuration', + minLength: 1, + }, + data: { + description: 'Object with field values for the new record. Accepts either an object or a JSON string.', + oneOf: [ + {type: 'object'}, + {type: 'string'}, + ], + }, + }, + required: ['model', 'data'], + additionalProperties: false, + }, + execute: async (input: any, runContext?: RunContext) => { + const activeUser = runContext?.context?.user ?? user; + return this.executeCreateModelRecord(input, activeUser); }, }); @@ -144,19 +152,138 @@ export class OpenAiDataAgentService extends AbstractAiModelService { 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.', + 'Always rely on the provided tools to inspect or modify database records.', + 'Use JSON commands when calling tools. Example: {"action":"create_model_record","model":"Example","data":{"title":"Hello"}}.', 'Only include fields that are relevant to the question.', 'Summaries should explain how the answer was derived from the data.', '', 'Accessible models:', modelSummary, ].join('\n'), - handoffDescription: 'Retrieves Adminizer records using DataAccessor with full permission checks.', - tools: [dataQueryTool], + handoffDescription: 'Retrieves and mutates Adminizer records using DataAccessor with full permission checks.', + tools: [dataQueryTool, createRecordTool], model: this.model, }); } + private async executeQueryModelRecords(input: any, activeUser: UserAP): Promise { + 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 accessor = new DataAccessor(this.adminizer, activeUser, entity, 'list'); + let criteria = {}; + if (input.filter && typeof input.filter === 'string' && input.filter.trim()) { + try { + criteria = JSON.parse(input.filter); + } catch (e) { + throw new Error('Invalid filter JSON'); + } + } + + const records = await entity.model.find(criteria, accessor); + const limit = typeof input.limit === 'number' ? input.limit : 10; + const limited = records.slice(0, limit); + const projected = Array.isArray(input.fields) && input.fields.length > 0 + ? limited.map((record) => this.pickFields(record, input.fields ?? [])) + : limited; + + return JSON.stringify({ + model: entity.name, + count: projected.length, + records: projected, + }, null, 2); + } + + private async executeCreateModelRecord(input: any, activeUser: UserAP): Promise { + 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 token = `add-${entity.model.modelname}-model`; + if (!this.adminizer.accessRightsHelper.hasPermission(token, activeUser)) { + throw new Error(`You do not have permission to create records in the "${entity.name}" model.`); + } + + const accessor = new DataAccessor(this.adminizer, activeUser, entity, 'add'); + const payload = this.parseRecordInput(input.data); + const record = await entity.model.create(payload, accessor); + + return JSON.stringify({ + model: entity.name, + record, + }, null, 2); + } + + private parseRecordInput(raw: unknown): Record { + if (raw === null || raw === undefined) { + throw new Error('Record data must be provided.'); + } + + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch (error) { + throw new Error('Invalid JSON provided for record data.'); + } + + throw new Error('Record data string must be a valid JSON object.'); + } + + if (typeof raw === 'object' && !Array.isArray(raw)) { + return raw as Record; + } + + throw new Error('Record data must be an object or a JSON string representing an object.'); + } + + private tryParseDirectCommand(prompt: string): Record | null { + const trimmed = prompt.trim(); + if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) { + return null; + } + + try { + const parsed = JSON.parse(trimmed); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && typeof parsed['action'] === 'string') { + return parsed as Record; + } + } catch (error) { + Adminizer.log.warn('[OpenAiDataAgentService] Failed to parse direct JSON command', error); + return null; + } + + return null; + } + + private async executeDirectCommand(command: Record, user: UserAP): Promise { + const action = typeof command['action'] === 'string' ? command['action'] : undefined; + if (!action) { + throw new Error('Direct command is missing an "action" field.'); + } + switch (action) { + case 'query_model_records': + return this.executeQueryModelRecords(command, user); + case 'create_model_record': + return this.executeCreateModelRecord(command, user); + default: + throw new Error(`Unknown command action: ${String(action)}`); + } + } + private pickFields>(record: T, fields: string[]): Partial { return fields.reduce>((acc, field) => { if (field in record) {