diff --git a/HISTORY.md b/HISTORY.md index 2c808728..f28c1b18 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,7 @@ +## 4.3.8 +- Added a single OpenAI data agent in the fixture with JSON-based query and mutation tools that honour `DataAccessor` permissions. +- Documented the JSON instruction format for querying, creating, updating, and deleting records through the assistant. + ## 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/openai-data-agent-json-instructions.md b/docs/openai-data-agent-json-instructions.md new file mode 100644 index 00000000..dbc1d068 --- /dev/null +++ b/docs/openai-data-agent-json-instructions.md @@ -0,0 +1,84 @@ +# OpenAI Data Agent JSON Instructions + +The fixture registers a single AI assistant model that proxies all read/write requests through `DataAccessor`. Every interaction must be expressed as JSON so the agent can safely execute the request with the currently authenticated user's permissions. + +## Query records + +Use the `query_model_records` tool whenever the assistant needs to read data. Provide the model name and, optionally, filters, field projections, and a limit. + +```json +{ + "action": "query", + "tool": "query_model_records", + "args": { + "model": "Example", + "filter": { "title": { "contains": "demo" } }, + "fields": ["id", "title", "ownerId"], + "limit": 5 + } +} +``` + +The agent will call `DataAccessor` with the authenticated user, ensuring that only permitted rows and fields are returned. + +## Create records + +To create a record, call the `mutate_model_records` tool with the `create` action and a JSON payload that contains only the fields that should be written. + +```json +{ + "action": "mutate", + "tool": "mutate_model_records", + "args": { + "action": "create", + "model": "Test", + "data": { + "title": "Created from AI", + "sort": true + } + } +} +``` + +The tool filters the payload against the user's writable fields before calling `model.create()`. + +## Update or delete records + +When editing or deleting data, include either an `id` or a full `criteria` object to select the target record. + +```json +{ + "action": "mutate", + "tool": "mutate_model_records", + "args": { + "action": "update", + "model": "Example", + "id": 42, + "data": { + "title": "Updated by AI" + } + } +} +``` + +```json +{ + "action": "mutate", + "tool": "mutate_model_records", + "args": { + "action": "delete", + "model": "Example", + "criteria": { "title": "Deprecated" } + } +} +``` + +The helper automatically merges `id` into the criteria and rejects empty selectors so records cannot be modified accidentally. + +## Permission handling + +- **Read access** relies on `new DataAccessor(adminizer, user, entity, 'list')`. +- **Create access** uses `'add'`, **update access** uses `'edit'`, and **delete access** uses `'remove'`. +- If the current user is missing a permission, the tool throws an explicit error message instead of performing the operation. + +This structure ensures the assistant can safely work with Adminizer data while respecting the authenticated user's capabilities. diff --git a/fixture/helpers/ai/OpenAiDataAgentService.ts b/fixture/helpers/ai/OpenAiDataAgentService.ts index 321744f7..aa7994aa 100644 --- a/fixture/helpers/ai/OpenAiDataAgentService.ts +++ b/fixture/helpers/ai/OpenAiDataAgentService.ts @@ -1,4 +1,3 @@ -import {z} from 'zod'; import { Agent, AgentInputItem, @@ -69,6 +68,7 @@ export class OpenAiDataAgentService extends AbstractAiModelService { } } + private createAgent(user: UserAP): Agent { const accessibleModels = this.listReadableModels(user); const modelSummary = accessibleModels.length > 0 @@ -87,46 +87,40 @@ export class OpenAiDataAgentService extends AbstractAiModelService { minLength: 1 }, filter: { - type: 'string', - description: 'Optional filter as a JSON string matching the model criteria' + type: 'object', + description: 'Optional filter expressed as JSON using Adminizer criteria operators.', + additionalProperties: true }, fields: { type: 'array', items: { type: 'string', minLength: 1 }, - description: 'Optional list of fields to include in the response' + description: 'Optional list of fields to include in the response', }, limit: { type: 'number', minimum: 1, maximum: 50, - description: 'Maximum number of records to return (default 10).' + 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.`); } 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 filter = this.ensurePlainObject(input.filter ?? {}); + const records = await entity.model.find(filter, 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 ?? [])) @@ -140,19 +134,118 @@ export class OpenAiDataAgentService extends AbstractAiModelService { }, }); + const dataMutationTool = tool({ + name: 'mutate_model_records', + description: 'Create, update, or delete Adminizer model records with DataAccessor permissions.', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + enum: ['create', 'update', 'delete'], + description: 'Mutation type to perform on the target model.' + }, + model: { + type: 'string', + description: 'Model name as defined in the Adminizer configuration', + minLength: 1 + }, + data: { + type: 'object', + description: 'Field values to apply when creating or updating a record.', + additionalProperties: true + }, + criteria: { + type: 'object', + description: 'Optional criteria object used to select a record for update/delete.', + additionalProperties: true + }, + id: { + description: 'Optional identifier helper used when targeting a specific record.', + anyOf: [ + {type: 'string', minLength: 1}, + {type: 'number'} + ] + } + }, + required: ['action', '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.`); + } + + const action = String(input.action); + + switch (action) { + case 'create': { + const accessor = new DataAccessor(this.adminizer, activeUser, entity, 'add'); + const payload = this.filterWritableData(input.data, accessor); + if (Object.keys(payload).length === 0) { + throw new Error('Provide at least one writable field in "data" to create a record.'); + } + const created = await entity.model.create(payload, accessor); + return JSON.stringify({ + model: entity.name, + record: created, + }, null, 2); + } + case 'update': { + const accessor = new DataAccessor(this.adminizer, activeUser, entity, 'edit'); + const payload = this.filterWritableData(input.data, accessor); + if (Object.keys(payload).length === 0) { + throw new Error('Provide at least one writable field in "data" to update a record.'); + } + const criteria = this.buildCriteria(input.criteria, input.id); + const updated = await entity.model.updateOne(criteria, payload, accessor); + if (!updated) { + throw new Error('No matching record was found or you do not have permission to update it.'); + } + return JSON.stringify({ + model: entity.name, + record: updated, + }, null, 2); + } + case 'delete': { + const accessor = new DataAccessor(this.adminizer, activeUser, entity, 'remove'); + const criteria = this.buildCriteria(input.criteria, input.id); + const removed = await entity.model.destroyOne(criteria, accessor); + if (!removed) { + throw new Error('No matching record was found or you do not have permission to delete it.'); + } + return JSON.stringify({ + model: entity.name, + record: removed, + }, null, 2); + } + default: + throw new Error('Unsupported action. Use "create", "update", or "delete".'); + } + }, + }); + 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.', + 'Always rely on the provided tools to inspect or change database records.', + 'All tool calls must be expressed as JSON instructions, e.g. {"action":"create","model":"Example","data":{...}}.', + 'Only include fields that are relevant to the question and respect user permissions.', '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], + tools: [dataQueryTool, dataMutationTool], model: this.model, }); } @@ -166,6 +259,45 @@ export class OpenAiDataAgentService extends AbstractAiModelService { }, {}); } + private ensurePlainObject>(value: unknown): T { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('Expected a JSON object.'); + } + + return value as T; + } + + private filterWritableData( + rawData: unknown, + accessor: DataAccessor, + ): Record { + const data = rawData ? this.ensurePlainObject>(rawData) : {}; + const fieldsConfig = accessor.getFieldsConfig(); + + if (!fieldsConfig) { + throw new Error('You do not have permission to modify this model.'); + } + + const writableEntries = Object.entries(data) + .filter(([key]) => Boolean(fieldsConfig[key])); + + return Object.fromEntries(writableEntries); + } + + private buildCriteria(criteriaInput: unknown, id: unknown): Record { + const criteria = criteriaInput ? this.ensurePlainObject>(criteriaInput) : {}; + + if (id !== undefined) { + criteria.id = id; + } + + if (Object.keys(criteria).length === 0) { + throw new Error('Provide either "id" or "criteria" to target a record.'); + } + + return criteria; + } + private toAgentInput(history: AiAssistantMessage[]): AgentInputItem[] { return history.map((message) => { if (message.role === 'user') { diff --git a/fixture/index.ts b/fixture/index.ts index e9c6e7be..44c026f5 100644 --- a/fixture/index.ts +++ b/fixture/index.ts @@ -179,6 +179,7 @@ async function ormSharedFixtureLift(adminizer: Adminizer) { // Register OpenAI data agent after init if API key is available if (adminizer.config.aiAssistant?.enabled) { + adminizer.aiAssistantHandler.clearModels(); const openAiAgent = new OpenAiDataAgentService(adminizer); if (openAiAgent.isEnabled()) { adminizer.aiAssistantHandler.registerModel(openAiAgent); diff --git a/src/lib/ai-assistant/AiAssistantHandler.ts b/src/lib/ai-assistant/AiAssistantHandler.ts index 42529c4c..98dd4b41 100644 --- a/src/lib/ai-assistant/AiAssistantHandler.ts +++ b/src/lib/ai-assistant/AiAssistantHandler.ts @@ -19,6 +19,10 @@ export class AiAssistantHandler { this.models.set(service.id, service); } + clearModels(): void { + this.models.clear(); + } + getModel(id: string): AbstractAiModelService | undefined { return this.models.get(id); }