Skip to content

Commit c8b70fd

Browse files
anna-shakhovaRaushen
andauthored
AI Assistant: adjust command descriptions (DevExpress#33636)
Co-authored-by: Raushen <eylau-andrew@mail.ru>
1 parent c964ee6 commit c8b70fd

10 files changed

Lines changed: 94 additions & 80 deletions

File tree

packages/devextreme/js/__internal/core/ai_integration/templates/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,7 @@ export const templates: PromptTemplates = {
4242
user: 'User prompt text: {{text}}. Dataset: {{data}}.',
4343
},
4444
executeGridAssistant: {
45-
// TODO: Implement system prompt when grid operations are ready
46-
system: 'You are a helpful AI assistant for a data grid component. The user sends a natural language request describing an operation on the grid (e.g., sorting, filtering, grouping). You receive the user\'s message, a context object describing the current grid state, and a JSON schema describing the available commands and their arguments. Your task is to interpret the user\'s request and return a JSON object with one field: "actions" — an array of command objects, each with "name" (the command name) and "args" (an object of arguments matching the schema). Output must be a valid JSON string, directly parsable by JSON.parse. Do not include any markdown, formatting, or extra text — only the raw JSON object.',
45+
system: 'You are a helpful AI assistant for a data grid component. The user sends a natural language request describing an operation on the grid (e.g., sorting, filtering, grouping). You receive the user\'s message, a context object describing the current grid state, and a JSON schema describing the available commands and their arguments. Your task is to interpret the user\'s request and return a JSON object with one field: "actions" — an array of command objects, each with "name" (the command name) and "args" (an object of arguments matching the schema). Output must be a valid JSON string, directly parsable by JSON.parse. Do not include any markdown, formatting, or extra text — only the raw JSON object.\n\nCRITICAL RULE FOR OPTIONAL ARGUMENTS: If an optional argument is not used, the field MUST be ENTIRELY ABSENT from the JSON object — the key must not appear at all. NEVER emit an optional field with value null, empty string "", empty array [], or any placeholder. This rule overrides any instinct to "complete" the object — omitted IS the value.',
4746
user: 'User request: {{text}}. Grid context: {{context}}.',
4847
},
4948
};

packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/__tests__/summary.test.ts

Lines changed: 47 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ describe('summaryCommand', () => {
5757
])('accepts summaryType "%s"', (summaryType) => {
5858
expect(summaryCommand.schema.safeParse({
5959
totalItems: [{ column: 'amount', summaryType }],
60+
groupItems: [],
6061
}).success).toBe(true);
6162
});
6263

@@ -67,41 +68,59 @@ describe('summaryCommand', () => {
6768
}).success).toBe(true);
6869
});
6970

70-
it('accepts empty object (executability layer rejects it)', () => {
71-
expect(summaryCommand.schema.safeParse({}).success).toBe(true);
71+
it('rejects empty object (totalItems and groupItems are required)', () => {
72+
expect(summaryCommand.schema.safeParse({}).success).toBe(false);
73+
});
74+
75+
it('rejects when totalItems is omitted', () => {
76+
expect(summaryCommand.schema.safeParse({
77+
groupItems: [{ column: 'amount', summaryType: 'avg' }],
78+
}).success).toBe(false);
79+
});
80+
81+
it('rejects when groupItems is omitted', () => {
82+
expect(summaryCommand.schema.safeParse({
83+
totalItems: [{ column: 'amount', summaryType: 'sum' }],
84+
}).success).toBe(false);
7285
});
7386

7487
it('rejects an unknown summaryType (including "custom")', () => {
7588
expect(summaryCommand.schema.safeParse({
7689
totalItems: [{ column: 'amount', summaryType: 'custom' }],
90+
groupItems: [],
7791
}).success).toBe(false);
7892
expect(summaryCommand.schema.safeParse({
7993
totalItems: [{ column: 'amount', summaryType: 'median' }],
94+
groupItems: [],
8095
}).success).toBe(false);
8196
});
8297

8398
it('rejects when an item is missing column', () => {
8499
expect(summaryCommand.schema.safeParse({
85100
totalItems: [{ summaryType: 'sum' }],
101+
groupItems: [],
86102
}).success).toBe(false);
87103
});
88104

89105
it('rejects when an item is missing summaryType', () => {
90106
expect(summaryCommand.schema.safeParse({
91107
totalItems: [{ column: 'amount' }],
108+
groupItems: [],
92109
}).success).toBe(false);
93110
});
94111

95112
it('rejects unknown properties on the root', () => {
96113
expect(summaryCommand.schema.safeParse({
97114
totalItems: [{ column: 'amount', summaryType: 'sum' }],
115+
groupItems: [],
98116
extra: 1,
99117
}).success).toBe(false);
100118
});
101119

102120
it('rejects unknown properties on the item', () => {
103121
expect(summaryCommand.schema.safeParse({
104122
totalItems: [{ column: 'amount', summaryType: 'sum', extra: 1 }],
123+
groupItems: [],
105124
}).success).toBe(false);
106125
});
107126

@@ -113,11 +132,13 @@ describe('summaryCommand', () => {
113132
showInColumn: 'name',
114133
displayFormat: 'Sum: {0}',
115134
}],
135+
groupItems: [],
116136
}).success).toBe(true);
117137
});
118138

119139
it('accepts showInColumn, displayFormat, showInGroupFooter, alignByColumn on groupItems', () => {
120140
expect(summaryCommand.schema.safeParse({
141+
totalItems: [],
121142
groupItems: [{
122143
column: 'amount',
123144
summaryType: 'avg',
@@ -136,6 +157,7 @@ describe('summaryCommand', () => {
136157
summaryType: 'sum',
137158
showInGroupFooter: true,
138159
}],
160+
groupItems: [],
139161
}).success).toBe(false);
140162
});
141163

@@ -146,47 +168,41 @@ describe('summaryCommand', () => {
146168
summaryType: 'sum',
147169
alignByColumn: true,
148170
}],
171+
groupItems: [],
149172
}).success).toBe(false);
150173
});
151174

152175
it('rejects when showInColumn is not a string', () => {
153176
expect(summaryCommand.schema.safeParse({
154177
totalItems: [{ column: 'amount', summaryType: 'sum', showInColumn: 1 }],
178+
groupItems: [],
155179
}).success).toBe(false);
156180
});
157181

158182
it('rejects when displayFormat is not a string', () => {
159183
expect(summaryCommand.schema.safeParse({
160184
totalItems: [{ column: 'amount', summaryType: 'sum', displayFormat: 5 }],
185+
groupItems: [],
161186
}).success).toBe(false);
162187
});
163188

164189
it('rejects when showInGroupFooter is not a boolean', () => {
165190
expect(summaryCommand.schema.safeParse({
191+
totalItems: [],
166192
groupItems: [{ column: 'amount', summaryType: 'sum', showInGroupFooter: 'yes' }],
167193
}).success).toBe(false);
168194
});
169195

170196
it('rejects when alignByColumn is not a boolean', () => {
171197
expect(summaryCommand.schema.safeParse({
198+
totalItems: [],
172199
groupItems: [{ column: 'amount', summaryType: 'sum', alignByColumn: 1 }],
173200
}).success).toBe(false);
174201
});
175202
});
176203

177204
describe('execute', () => {
178-
it('returns failure when both totalItems and groupItems are empty/omitted', async () => {
179-
const instance = await createGrid();
180-
const optionSpy = jest.spyOn(instance, 'option');
181-
const callbacks = createCallbacks();
182-
183-
const result = await summaryCommand.execute(instance, callbacks)({});
184-
185-
expect(result.status).toBe('failure');
186-
expect(optionSpy).not.toHaveBeenCalledWith('summary', expect.anything());
187-
});
188-
189-
it('returns failure when both arrays are explicitly empty', async () => {
205+
it('returns failure when both arrays are empty', async () => {
190206
const instance = await createGrid();
191207
const optionSpy = jest.spyOn(instance, 'option');
192208
const callbacks = createCallbacks();
@@ -210,6 +226,7 @@ describe('summaryCommand', () => {
210226
{ column: 'amount', summaryType: 'sum' },
211227
{ column: 'unknown', summaryType: 'avg' },
212228
],
229+
groupItems: [],
213230
});
214231

215232
expect(result.status).toBe('failure');
@@ -222,6 +239,7 @@ describe('summaryCommand', () => {
222239
const callbacks = createCallbacks();
223240

224241
const result = await summaryCommand.execute(instance, callbacks)({
242+
totalItems: [],
225243
groupItems: [{ column: 'unknown', summaryType: 'sum' }],
226244
});
227245

@@ -240,6 +258,7 @@ describe('summaryCommand', () => {
240258
summaryType: 'sum',
241259
showInColumn: 'unknown',
242260
}],
261+
groupItems: [],
243262
});
244263

245264
expect(result.status).toBe('failure');
@@ -263,21 +282,6 @@ describe('summaryCommand', () => {
263282
expect(result.status).toBe('success');
264283
});
265284

266-
it('passes empty arrays when one of the inputs is omitted', async () => {
267-
const instance = await createGrid();
268-
const optionSpy = jest.spyOn(instance, 'option');
269-
const callbacks = createCallbacks();
270-
271-
await summaryCommand.execute(instance, callbacks)({
272-
totalItems: [{ column: 'amount', summaryType: 'sum' }],
273-
});
274-
275-
expect(optionSpy).toHaveBeenCalledWith('summary', {
276-
totalItems: [{ column: 'amount', summaryType: 'sum' }],
277-
groupItems: [],
278-
});
279-
});
280-
281285
it('returns failure when option throws', async () => {
282286
const instance = await createGrid();
283287
const realOption = instance.option.bind(instance);
@@ -291,6 +295,7 @@ describe('summaryCommand', () => {
291295

292296
const result = await summaryCommand.execute(instance, callbacks)({
293297
totalItems: [{ column: 'amount', summaryType: 'sum' }],
298+
groupItems: [],
294299
});
295300

296301
expect(result.status).toBe('failure');
@@ -304,6 +309,7 @@ describe('summaryCommand', () => {
304309

305310
await summaryCommand.execute(instance, callbacks)({
306311
totalItems: [{ column: 'amount', summaryType: 'sum' }],
312+
groupItems: [],
307313
});
308314

309315
expect(callbacks.success).toHaveBeenCalledWith(
@@ -316,6 +322,7 @@ describe('summaryCommand', () => {
316322
const callbacks = createCallbacks();
317323

318324
await summaryCommand.execute(instance, callbacks)({
325+
totalItems: [],
319326
groupItems: [{ column: 'amount', summaryType: 'avg' }],
320327
});
321328

@@ -336,6 +343,7 @@ describe('summaryCommand', () => {
336343

337344
await summaryCommand.execute(instance, callbacks)({
338345
totalItems: [{ column: 'amount', summaryType: summaryType as 'sum' | 'min' | 'max' | 'avg' | 'count' }],
346+
groupItems: [],
339347
});
340348

341349
expect(callbacks.success).toHaveBeenCalledWith(
@@ -349,6 +357,7 @@ describe('summaryCommand', () => {
349357

350358
await summaryCommand.execute(instance, callbacks)({
351359
totalItems: [{ column: 'name', summaryType: 'count' }],
360+
groupItems: [],
352361
});
353362

354363
// 'name' has no explicit caption — DevExtreme auto-derives "Name"
@@ -366,6 +375,7 @@ describe('summaryCommand', () => {
366375
{ column: 'amount', summaryType: 'sum' },
367376
{ column: 'amount', summaryType: 'avg' },
368377
],
378+
groupItems: [],
369379
});
370380

371381
expect(callbacks.success).toHaveBeenCalledWith(
@@ -391,7 +401,10 @@ describe('summaryCommand', () => {
391401
const instance = await createGrid();
392402
const callbacks = createCallbacks();
393403

394-
await summaryCommand.execute(instance, callbacks)({});
404+
await summaryCommand.execute(instance, callbacks)({
405+
totalItems: [],
406+
groupItems: [],
407+
});
395408

396409
expect(callbacks.failure).toHaveBeenCalledWith('Display data summaries.');
397410
});
@@ -403,6 +416,7 @@ describe('summaryCommand', () => {
403416
// Single item with unresolved column → failure path, item-list message
404417
await summaryCommand.execute(instance, callbacks)({
405418
totalItems: [{ column: 'unknown', summaryType: 'sum' }],
419+
groupItems: [],
406420
});
407421

408422
expect(callbacks.failure).toHaveBeenCalledWith(
@@ -427,16 +441,16 @@ describe('clearSummaryCommand', () => {
427441
});
428442

429443
describe('execute', () => {
430-
it('calls component.option("summary", { groupItems: undefined, totalItems: undefined }) on success', async () => {
444+
it('calls component.option("summary", { groupItems: [], totalItems: [] }) on success', async () => {
431445
const instance = await createGrid();
432446
const optionSpy = jest.spyOn(instance, 'option');
433447
const callbacks = createCallbacks();
434448

435449
const result = await clearSummaryCommand.execute(instance, callbacks)();
436450

437451
expect(optionSpy).toHaveBeenCalledWith('summary', {
438-
groupItems: undefined,
439-
totalItems: undefined,
452+
groupItems: [],
453+
totalItems: [],
440454
});
441455
expect(result.status).toBe('success');
442456
});

packages/devextreme/js/__internal/grids/data_grid/ai_assistant/commands/summary.ts

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ const groupItemSchema = z.object({
2323
}).strict();
2424

2525
const summaryCommandSchema = z.object({
26-
totalItems: z.array(totalItemSchema).optional(),
27-
groupItems: z.array(groupItemSchema).optional(),
26+
totalItems: z.array(totalItemSchema),
27+
groupItems: z.array(groupItemSchema),
2828
}).strict();
2929

3030
type TotalItem = z.infer<typeof totalItemSchema>;
@@ -69,20 +69,19 @@ const buildDefaultMessage = (
6969

7070
export const summaryCommand = defineGridCommand({
7171
name: 'summary',
72-
description: 'Configure column summaries. totalItems aggregate the entire data set; groupItems aggregate within each group. Provide at least one item across the two arrays — use clearSummary to remove all summaries. Replaces existing summaries entirely; pre-merge with the grid\'s current summary items if you intend to add rather than replace.\n'
72+
description: 'Configure column summaries. totalItems aggregate the entire data set; groupItems aggregate within each group. Replaces the configuration entirely — both kinds are written every call. ALWAYS provide BOTH totalItems and groupItems; pass an explicit empty array [] for a kind to clear it. To keep one kind unchanged, copy its current items from the grid context into your args. To ADD items, pre-merge with the kind\'s current items. To REMOVE a specific item, pass the kind\'s remaining items (and copy the other kind unchanged). At least one kind must be non-empty. Use clearSummary only when EVERY summary should be removed.\n'
7373
+ 'Each item supports:\n'
74-
+ '- column (required): dataField of the column to aggregate.\n'
74+
+ '- column (required): dataField of the column whose VALUES are aggregated. Phrases like "sum of X", "average X", "total X", "summary for X" → column="X" (X is what gets aggregated).\n'
7575
+ '- summaryType (required): one of "sum", "min", "max", "avg", "count".\n'
76-
+ '- showInColumn (optional): dataField of the column under which the summary value is rendered. For totalItems, controls which column\'s footer cell shows the value. For groupItems, used when showInGroupFooter=true or alignByColumn=true to pick the column the value is shown under. Must match an existing column.\n'
76+
+ '- showInColumn (optional): dataField of the column where the value is DISPLAYED (not aggregated). OMIT unless the user explicitly names a second column with a phrase like "show in Y", "display under Y", "in the Y column" → showInColumn="Y". Example: "sum of Amount in the SaleDate column" → column="Amount", showInColumn="SaleDate". One column mentioned → OMIT. For totalItems, controls which footer cell shows the value. For groupItems, showInColumn has effect ONLY when paired with showInGroupFooter=true OR alignByColumn=true — so whenever you set showInColumn on a group item, you MUST also set alignByColumn=true (the default pairing), unless the user explicitly asked for footer placement (then set showInGroupFooter=true instead).\n'
7777
+ '- displayFormat (optional): format template for the rendered value. Placeholders: "{0}" — the formatted summary value; "{1}" — the parent column\'s caption (for group items only resolvable when showInColumn is specified). Example: "Sum: {0}" or "{1}: {0}".\n'
7878
+ 'Group items additionally support:\n'
79-
+ '- showInGroupFooter (optional, default false): render in the group footer instead of the group row.\n'
80-
+ '- alignByColumn (optional, default false): when false, group summary items are listed in parentheses after the group row header. When true, items are aligned by their columns within the group row.',
79+
+ '- showInGroupFooter (optional): OMIT this field unless the user explicitly requests the group footer area. Default behavior renders the summary in the group row (the header that displays the group value). Set to true ONLY when the user explicitly says "group footer", "below the group", or "in the footer". Requests like "in the header", "in the group row", "next to the group name", or no placement mention at all → OMIT (do not set to false either; just omit the field).\n'
80+
+ '- alignByColumn (optional): OMIT this field unless the user explicitly requests column-aligned layout. Default behavior lists items in parentheses after the group row caption (e.g. "Category: Bikes (Sum: 100, Count: 5)"). Set to true ONLY when the user explicitly asks to "align by column", "show under each column", or "align with the column". Otherwise OMIT.',
8181
schema: summaryCommandSchema,
8282
execute: (component, { success, failure }) => (args): Promise<CommandResult> => {
8383
const columnsController = component.getController('columns');
84-
const totalItems = args.totalItems ?? [];
85-
const groupItems = args.groupItems ?? [];
84+
const { totalItems, groupItems } = args;
8685
const allItems: SummaryItem[] = [...totalItems, ...groupItems];
8786

8887
const defaultMessage = buildDefaultMessage(totalItems, groupItems, columnsController);
@@ -108,10 +107,7 @@ export const summaryCommand = defineGridCommand({
108107
}
109108

110109
try {
111-
component.option('summary', {
112-
totalItems: args.totalItems ?? [],
113-
groupItems: args.groupItems ?? [],
114-
});
110+
component.option('summary', { totalItems, groupItems });
115111

116112
return Promise.resolve(success(defaultMessage));
117113
} catch {
@@ -122,15 +118,15 @@ export const summaryCommand = defineGridCommand({
122118

123119
export const clearSummaryCommand = defineGridCommand({
124120
name: 'clearSummary',
125-
description: 'Remove all summary items.',
121+
description: 'Remove ALL summary items. Do NOT call this for partial removals. Use only when every summary should be cleared (both totalItems and groupItems). To remove a subset — e.g., clear only totalItems while keeping groupItems (or vice versa), or drop a specific item — call the summary command with the items that should remain, since summary replaces existing summaries entirely.',
126122
schema: z.object({}).strict(),
127123
execute: (component, { success, failure }) => (): Promise<CommandResult> => {
128124
const defaultMessage = 'Clear column summaries.';
129125

130126
try {
131127
component.option('summary', {
132-
groupItems: undefined,
133-
totalItems: undefined,
128+
groupItems: [],
129+
totalItems: [],
134130
});
135131

136132
return Promise.resolve(success(defaultMessage));

packages/devextreme/js/__internal/grids/grid_core/ai_assistant/commands/__tests__/paging.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ describe('pageIndexCommand', () => {
318318

319319
await pageIndexCommand.execute(instance, callbacks)({ pageIndex: 1 });
320320

321-
expect(callbacks.success).toHaveBeenCalledWith('Switch the view to page number 1.');
321+
expect(callbacks.success).toHaveBeenCalledWith('Switch the view to page number 2.');
322322
});
323323

324324
it('passes the same default message to failure when executability fails', async () => {
@@ -327,7 +327,7 @@ describe('pageIndexCommand', () => {
327327

328328
await pageIndexCommand.execute(instance, callbacks)({ pageIndex: 2 });
329329

330-
expect(callbacks.failure).toHaveBeenCalledWith('Switch the view to page number 2.');
330+
expect(callbacks.failure).toHaveBeenCalledWith('Switch the view to page number 3.');
331331
});
332332
});
333333
});

0 commit comments

Comments
 (0)