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
2 changes: 2 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
29 changes: 14 additions & 15 deletions docs/AiAssistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
139 changes: 133 additions & 6 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 All @@ -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 {
Expand Down Expand Up @@ -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<AgentContext>) => {
Expand Down Expand Up @@ -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<AgentContext>) => {
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<AgentContext>) => {
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<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.',
'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,
});
}
Expand Down Expand Up @@ -266,4 +367,30 @@ export class OpenAiDataAgentService extends AbstractAiModelService {

return config;
}

private parsePayload(data: unknown): Record<string, unknown> {
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<string, unknown>;
}

throw new Error('Payload must be either a JSON string or an object.');
}

private restrictPayloadToAccessibleFields(
payload: Record<string, unknown>,
metadata: FieldMetadata[],
): Record<string, unknown> {
const allowedFields = new Set(metadata.map((field) => field.name));
return Object.fromEntries(
Object.entries(payload).filter(([key]) => allowedFields.has(key)),
);
}
}
76 changes: 76 additions & 0 deletions src/lib/DataAccessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down