diff --git a/HISTORY.md b/HISTORY.md index 70354f92..089eff75 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,4 +1,6 @@ ## 4.3.6 +- Added a `DataAccessor.listFieldMetadata` helper that surfaces accessible fields with descriptions for permission-aware flows. +- Extended the fixture OpenAI data agent with field-discovery and record-creation tools driven by DataAccessor permissions. - Configured OpenAI API integration through environment variables for AI assistant functionality. - Added dotenv support for loading environment variables from .env file in fixture startup. - Created OpenAI setup documentation with security best practices and troubleshooting guide. diff --git a/docs/AiAssistant.md b/docs/AiAssistant.md index 75417d4e..9674831e 100644 --- a/docs/AiAssistant.md +++ b/docs/AiAssistant.md @@ -25,22 +25,19 @@ 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. - -Example payload for creating a record: - -```json -{ - "action": "create", - "entity": "Example", - "data": { - "title": "Hello from the assistant", - "description": "Generated through the OpenAI agent" - } -} +The fixture now also registers an `openai` model that executes structured commands against the database. The agent relies on `DataAccessor` for every operation, so the requesting user's permissions are always enforced—both for reading data and for creating new records. Two tools are exposed to the model: + +* `describe_model_fields` — surfaces the list of fields available for a specific action (`add`, `edit`, `list`, or `view`) along with descriptions, required flags, and association metadata. The tool is powered by `DataAccessor.listFieldMetadata()`. +* `create_model_record` — creates a record through `DataAccessor` using the caller's permissions. The payload can be a JSON object or a JSON string. Any fields outside of the allowed set are ignored before persisting the record. + +The recommended workflow is to ask the agent to call `describe_model_fields` for the desired model and action, then construct a `create_model_record` payload that includes the required fields. For example, creating an `Example` record from the chat can be accomplished with the following instruction: + +```text +Create an Example entry with title "Hello from the assistant" and description "Generated through the OpenAI agent". +Call describe_model_fields first so you can confirm the required fields, then call create_model_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. +If the user lacks the required access token (for example, `add-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. ## Backend Overview @@ -79,8 +76,10 @@ OpenAI's Agents API while still relying on Adminizer's abstractions: * The agent implementation lives in `fixture/helpers/ai/OpenAiDataAgentService.ts` and extends `AbstractAiModelService`. -* Database reads are performed through `DataAccessor`, which means the usual access control and field +* Database reads and writes are performed through `DataAccessor`, which means the usual access control and field sanitisation rules are enforced automatically. +* `DataAccessor.listFieldMetadata()` powers the field-discovery tool so the model can understand which columns are writable and + how to describe them when drafting payloads. * Conversation history is converted into the `@openai/agents` protocol so follow-up questions can build on previous answers. diff --git a/fixture/helpers/ai/OpenAiDataAgentService.ts b/fixture/helpers/ai/OpenAiDataAgentService.ts index 321744f7..554a3993 100644 --- a/fixture/helpers/ai/OpenAiDataAgentService.ts +++ b/fixture/helpers/ai/OpenAiDataAgentService.ts @@ -1,4 +1,3 @@ -import {z} from 'zod'; import { Agent, AgentInputItem, @@ -11,7 +10,8 @@ import {AbstractAiModelService} from '../../../dist/lib/ai-assistant/AbstractAiM import {AiAssistantMessage, Entity} from '../../../dist/interfaces/types'; import {ModelConfig} from '../../../dist/interfaces/adminpanelConfig'; import {Adminizer} from '../../../dist/lib/Adminizer'; -import {DataAccessor} from '../../../dist/lib/DataAccessor'; +import {DataAccessor, FieldMetadata} from '../../../dist/lib/DataAccessor'; +import {isObject} from '../../../dist/helpers/JsUtils'; import {UserAP} from '../../../dist/models/UserAP'; interface AgentContext { @@ -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,120 @@ export class OpenAiDataAgentService extends AbstractAiModelService { }, }); + const describeFieldsTool = tool({ + name: 'describe_model_fields', + description: 'List fields available for a given action, including descriptions and association details.', + parameters: { + type: 'object', + properties: { + model: { + type: 'string', + description: 'Model name as defined in the Adminizer configuration', + minLength: 1, + }, + action: { + type: 'string', + enum: ['list', 'view', 'add', 'edit'], + description: 'Action to describe (defaults to "add" to assist with record creation).', + }, + }, + required: ['model'], + additionalProperties: false, + }, + execute: async (input: any, runContext?: RunContext) => { + const activeUser = runContext?.context?.user ?? user; + const action = (input.action ?? 'add') as 'list' | 'view' | 'add' | 'edit'; + + 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, action); + const metadata = accessor.listFieldMetadata(); + + if (metadata.length === 0) { + return JSON.stringify({ + model: entity.name, + action, + fields: [], + message: 'No fields are accessible for this action with the current permissions.', + }, null, 2); + } + + return JSON.stringify({ + model: entity.name, + action, + fields: metadata, + }, null, 2); + }, + }); + + const createRecordTool = tool({ + name: 'create_model_record', + description: 'Create a new record in an Adminizer model using DataAccessor and the current user permissions.', + parameters: { + type: 'object', + properties: { + model: { + type: 'string', + description: 'Model name as defined in the Adminizer configuration', + minLength: 1, + }, + data: { + description: 'JSON object describing the fields for the new record.', + anyOf: [ + {type: 'string', minLength: 2}, + {type: 'object'}, + ], + }, + }, + required: ['model', 'data'], + additionalProperties: false, + }, + execute: async (input: any, runContext?: RunContext) => { + const activeUser = runContext?.context?.user ?? user; + + 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, 'add'); + const metadata = accessor.listFieldMetadata(); + if (metadata.length === 0) { + throw new Error('You do not have permission to create records for this model.'); + } + + const payload = this.parsePayload(input.data); + const filteredData = this.restrictPayloadToAccessibleFields(payload, metadata); + + if (Object.keys(filteredData).length === 0) { + throw new Error('None of the provided fields are allowed for creation.'); + } + + const created = await entity.model.create(filteredData, accessor); + return JSON.stringify({ + model: entity.name, + 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.', + 'Always rely on the provided tools to inspect database records or modify them.', + 'Use `describe_model_fields` before creating data to learn which fields are required and what they mean.', + 'Only include fields that are relevant to the question or action.', '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: [describeFieldsTool, dataQueryTool, createRecordTool], model: this.model, }); } @@ -266,4 +367,30 @@ export class OpenAiDataAgentService extends AbstractAiModelService { return config; } + + private parsePayload(data: unknown): Record { + if (typeof data === 'string') { + try { + return JSON.parse(data); + } catch (error) { + throw new Error('Unable to parse the provided JSON payload.'); + } + } + + if (isObject(data)) { + return data as Record; + } + + throw new Error('Payload must be either a JSON string or an object.'); + } + + private restrictPayloadToAccessibleFields( + payload: Record, + metadata: FieldMetadata[], + ): Record { + const allowedFields = new Set(metadata.map((field) => field.name)); + return Object.fromEntries( + Object.entries(payload).filter(([key]) => allowedFields.has(key)), + ); + } } diff --git a/src/lib/DataAccessor.ts b/src/lib/DataAccessor.ts index d2163ae0..1e0ba4d9 100644 --- a/src/lib/DataAccessor.ts +++ b/src/lib/DataAccessor.ts @@ -15,6 +15,17 @@ import {Adminizer} from "./Adminizer"; import { GroupAP } from "models/GroupAP"; import { UserAP } from "models/UserAP"; import { isObject } from "../helpers/JsUtils"; +import type {Attribute} from "./model/AbstractModel"; + +export interface FieldMetadata { + name: string; + label: string; + description?: string; + type: FieldsTypes | string; + required: boolean; + referenceModel?: string; + multiple?: boolean; +} export class DataAccessor { public readonly adminizer: Adminizer; @@ -155,6 +166,71 @@ export class DataAccessor { return result; } + /** + * Lists fields available for the current action together with human friendly metadata. + * The returned array is already filtered by access rights via {@link getFieldsConfig}. + */ + public listFieldMetadata(): FieldMetadata[] { + const fields = this.getFieldsConfig(); + + if (!fields) { + return []; + } + + return Object.entries(fields).map(([fieldName, field]): FieldMetadata => { + const config = field.config as BaseFieldConfig; + const modelField = field.model as Attribute | undefined; + const type = config.type ?? this.resolveModelFieldType(modelField); + const referenceModel = this.resolveReferenceModel(modelField); + + return { + name: fieldName, + label: config.title ?? fieldName, + description: config.tooltip, + type, + required: Boolean(config.required ?? (typeof modelField === 'object' && modelField?.required)), + referenceModel: referenceModel ?? undefined, + multiple: type === 'association-many' ? true : undefined, + }; + }); + } + + private resolveModelFieldType(modelField: Attribute | undefined): string { + if (!modelField) { + return 'string'; + } + + if (typeof modelField.type === 'string' && modelField.type.length > 0) { + return modelField.type; + } + + if (modelField.model) { + return 'association'; + } + + if (modelField.collection) { + return 'association-many'; + } + + return 'string'; + } + + private resolveReferenceModel(modelField: Attribute | undefined): string | null { + if (!modelField) { + return null; + } + + if (modelField.model) { + return modelField.model; + } + + if (modelField.collection) { + return modelField.collection; + } + + return null; + } + private getAssociatedFieldsConfig(modelName: string): { [fieldName: string]: Field } | undefined { const model = this.adminizer.modelHandler.model.get(modelName);