Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
41 changes: 19 additions & 22 deletions docs/AiAssistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,41 +25,38 @@ 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"
}
}
```

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

Expand Down
4 changes: 2 additions & 2 deletions fixture/adminizerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,8 @@ const config: AdminpanelConfig = {
},
aiAssistant: {
enabled: true,
defaultModel: 'openai',
models: ['openai'],
defaultModel: 'openai-data',
models: ['openai-data'],
},
routePrefix: routePrefix,
// routePrefix: "/admin",
Expand Down
195 changes: 161 additions & 34 deletions fixture/helpers/ai/OpenAiDataAgentService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import {z} from 'zod';
import {
Agent,
AgentInputItem,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -102,61 +111,179 @@ 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<AgentContext>) => {
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<AgentContext>) => {
const activeUser = runContext?.context?.user ?? user;
return this.executeCreateModelRecord(input, activeUser);
},
});

return new Agent<AgentContext>({
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<string> {
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<string> {
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<string, unknown> {
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<string, unknown>;
}
} 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<string, unknown>;
}

throw new Error('Record data must be an object or a JSON string representing an object.');
}

private tryParseDirectCommand(prompt: string): Record<string, unknown> | 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<string, unknown>;
}
} catch (error) {
Adminizer.log.warn('[OpenAiDataAgentService] Failed to parse direct JSON command', error);
return null;
}

return null;
}

private async executeDirectCommand(command: Record<string, unknown>, user: UserAP): Promise<string> {
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<T extends Record<string, unknown>>(record: T, fields: string[]): Partial<T> {
return fields.reduce<Partial<T>>((acc, field) => {
if (field in record) {
Expand Down