@memberjunction/ai-agents 5.37.0 → 5.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -0
- package/dist/AgentDataPreloader.d.ts +8 -17
- package/dist/AgentDataPreloader.d.ts.map +1 -1
- package/dist/AgentDataPreloader.js +53 -8
- package/dist/AgentDataPreloader.js.map +1 -1
- package/dist/AgentRunner.d.ts +19 -23
- package/dist/AgentRunner.d.ts.map +1 -1
- package/dist/AgentRunner.js +73 -167
- package/dist/AgentRunner.js.map +1 -1
- package/dist/agent-types/loop-agent-response-type.d.ts +9 -0
- package/dist/agent-types/loop-agent-response-type.d.ts.map +1 -1
- package/dist/agent-types/loop-agent-response-type.js.map +1 -1
- package/dist/agent-types/loop-agent-type.d.ts.map +1 -1
- package/dist/agent-types/loop-agent-type.js +22 -12
- package/dist/agent-types/loop-agent-type.js.map +1 -1
- package/dist/artifact-tools/DataSnapshotToolLibrary.d.ts +4 -0
- package/dist/artifact-tools/DataSnapshotToolLibrary.d.ts.map +1 -1
- package/dist/artifact-tools/DataSnapshotToolLibrary.js +507 -12
- package/dist/artifact-tools/DataSnapshotToolLibrary.js.map +1 -1
- package/dist/base-agent.d.ts +279 -80
- package/dist/base-agent.d.ts.map +1 -1
- package/dist/base-agent.js +638 -150
- package/dist/base-agent.js.map +1 -1
- package/package.json +17 -17
|
@@ -8,7 +8,8 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
8
8
|
* @fileoverview Artifact tool library for DataSnapshot content.
|
|
9
9
|
*
|
|
10
10
|
* Parses a DataSnapshot JSON string and exposes tools for inspecting
|
|
11
|
-
* table metadata, slicing rows, searching,
|
|
11
|
+
* table metadata, slicing rows, searching, aggregating, grouping,
|
|
12
|
+
* computing derived columns, and multi-condition filtering.
|
|
12
13
|
*/
|
|
13
14
|
import { RegisterClass } from '@memberjunction/global';
|
|
14
15
|
import { NormalizeToTables } from '@memberjunction/core';
|
|
@@ -18,8 +19,184 @@ const VALID_SEARCH_OPERATORS = new Set([
|
|
|
18
19
|
]);
|
|
19
20
|
const VALID_AGGREGATE_OPS = new Set([
|
|
20
21
|
'sum', 'avg', 'mean', 'count', 'min', 'max', 'distinct_count',
|
|
21
|
-
'median', 'mode', 'stdev', 'variance',
|
|
22
|
+
'median', 'percentile', 'mode', 'stdev', 'variance',
|
|
22
23
|
]);
|
|
24
|
+
const EXPR_FUNCTIONS = new Set([
|
|
25
|
+
'abs', 'round', 'floor', 'ceil', 'sqrt', 'min', 'max',
|
|
26
|
+
]);
|
|
27
|
+
class ExpressionParser {
|
|
28
|
+
constructor() {
|
|
29
|
+
this.tokens = [];
|
|
30
|
+
this.pos = 0;
|
|
31
|
+
}
|
|
32
|
+
/** Parse `expression` into an AST. Throws Error with a human-readable message on failure. */
|
|
33
|
+
parse(expression) {
|
|
34
|
+
this.tokens = this.tokenize(expression);
|
|
35
|
+
this.pos = 0;
|
|
36
|
+
if (this.tokens.length === 0) {
|
|
37
|
+
throw new Error('Expression is empty.');
|
|
38
|
+
}
|
|
39
|
+
const node = this.parseExpr();
|
|
40
|
+
if (this.pos < this.tokens.length) {
|
|
41
|
+
throw new Error(`Unexpected token "${this.peekRaw()}" in expression.`);
|
|
42
|
+
}
|
|
43
|
+
return node;
|
|
44
|
+
}
|
|
45
|
+
tokenize(src) {
|
|
46
|
+
const out = [];
|
|
47
|
+
let i = 0;
|
|
48
|
+
while (i < src.length) {
|
|
49
|
+
const ch = src[i];
|
|
50
|
+
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
|
51
|
+
i++;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if ('+-*/(),'.includes(ch)) {
|
|
55
|
+
out.push({ kind: 'op', value: ch });
|
|
56
|
+
i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (ch >= '0' && ch <= '9' || (ch === '.' && /[0-9]/.test(src[i + 1] ?? ''))) {
|
|
60
|
+
let j = i + 1;
|
|
61
|
+
while (j < src.length && /[0-9.]/.test(src[j]))
|
|
62
|
+
j++;
|
|
63
|
+
const numStr = src.slice(i, j);
|
|
64
|
+
const value = Number(numStr);
|
|
65
|
+
if (!Number.isFinite(value))
|
|
66
|
+
throw new Error(`Invalid number "${numStr}" in expression.`);
|
|
67
|
+
out.push({ kind: 'num', value });
|
|
68
|
+
i = j;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (/[A-Za-z_]/.test(ch)) {
|
|
72
|
+
let j = i + 1;
|
|
73
|
+
while (j < src.length && /[A-Za-z0-9_]/.test(src[j]))
|
|
74
|
+
j++;
|
|
75
|
+
out.push({ kind: 'ident', value: src.slice(i, j) });
|
|
76
|
+
i = j;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
throw new Error(`Unexpected character "${ch}" in expression. Only field names, numbers, + - * / ( ) , and functions (${[...EXPR_FUNCTIONS].join(', ')}) are allowed.`);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
peek() { return this.tokens[this.pos]; }
|
|
84
|
+
peekRaw() {
|
|
85
|
+
const t = this.peek();
|
|
86
|
+
return t ? String('value' in t ? t.value : '') : 'end of expression';
|
|
87
|
+
}
|
|
88
|
+
next() { return this.tokens[this.pos++]; }
|
|
89
|
+
isOp(v) {
|
|
90
|
+
const t = this.peek();
|
|
91
|
+
return !!t && t.kind === 'op' && t.value === v;
|
|
92
|
+
}
|
|
93
|
+
parseExpr() {
|
|
94
|
+
let left = this.parseTerm();
|
|
95
|
+
while (this.isOp('+') || this.isOp('-')) {
|
|
96
|
+
const op = this.next().value;
|
|
97
|
+
const right = this.parseTerm();
|
|
98
|
+
left = { type: 'binary', op, left, right };
|
|
99
|
+
}
|
|
100
|
+
return left;
|
|
101
|
+
}
|
|
102
|
+
parseTerm() {
|
|
103
|
+
let left = this.parseFactor();
|
|
104
|
+
while (this.isOp('*') || this.isOp('/')) {
|
|
105
|
+
const op = this.next().value;
|
|
106
|
+
const right = this.parseFactor();
|
|
107
|
+
left = { type: 'binary', op, left, right };
|
|
108
|
+
}
|
|
109
|
+
return left;
|
|
110
|
+
}
|
|
111
|
+
parseFactor() {
|
|
112
|
+
if (this.isOp('-')) {
|
|
113
|
+
this.next();
|
|
114
|
+
return { type: 'unary', operand: this.parseFactor() };
|
|
115
|
+
}
|
|
116
|
+
if (this.isOp('+')) {
|
|
117
|
+
this.next();
|
|
118
|
+
return this.parseFactor();
|
|
119
|
+
}
|
|
120
|
+
return this.parsePrimary();
|
|
121
|
+
}
|
|
122
|
+
parsePrimary() {
|
|
123
|
+
const t = this.next();
|
|
124
|
+
if (!t)
|
|
125
|
+
throw new Error('Unexpected end of expression.');
|
|
126
|
+
if (t.kind === 'num')
|
|
127
|
+
return { type: 'num', value: t.value };
|
|
128
|
+
if (t.kind === 'op' && t.value === '(') {
|
|
129
|
+
const inner = this.parseExpr();
|
|
130
|
+
if (!this.isOp(')'))
|
|
131
|
+
throw new Error('Missing closing ")" in expression.');
|
|
132
|
+
this.next();
|
|
133
|
+
return inner;
|
|
134
|
+
}
|
|
135
|
+
if (t.kind === 'ident') {
|
|
136
|
+
// function call?
|
|
137
|
+
if (this.isOp('(')) {
|
|
138
|
+
const name = t.value.toLowerCase();
|
|
139
|
+
if (!EXPR_FUNCTIONS.has(name)) {
|
|
140
|
+
throw new Error(`Unknown function "${t.value}". Allowed: ${[...EXPR_FUNCTIONS].join(', ')}.`);
|
|
141
|
+
}
|
|
142
|
+
this.next(); // consume '('
|
|
143
|
+
const args = [];
|
|
144
|
+
if (!this.isOp(')')) {
|
|
145
|
+
args.push(this.parseExpr());
|
|
146
|
+
while (this.isOp(',')) {
|
|
147
|
+
this.next();
|
|
148
|
+
args.push(this.parseExpr());
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (!this.isOp(')'))
|
|
152
|
+
throw new Error(`Missing closing ")" for function "${t.value}".`);
|
|
153
|
+
this.next();
|
|
154
|
+
return { type: 'call', name, args };
|
|
155
|
+
}
|
|
156
|
+
return { type: 'field', name: t.value };
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`Unexpected token "${'value' in t ? t.value : ''}" in expression.`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Evaluate a parsed expression against a row. Returns NaN when the expression
|
|
163
|
+
* references a missing/non-numeric field or produces a non-finite value; the
|
|
164
|
+
* caller converts NaN to null for output/sorting.
|
|
165
|
+
*/
|
|
166
|
+
function evaluateExpr(node, row) {
|
|
167
|
+
switch (node.type) {
|
|
168
|
+
case 'num': return node.value;
|
|
169
|
+
case 'field': {
|
|
170
|
+
const v = row[node.name];
|
|
171
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : NaN;
|
|
172
|
+
}
|
|
173
|
+
case 'unary': return -evaluateExpr(node.operand, row);
|
|
174
|
+
case 'binary': {
|
|
175
|
+
const l = evaluateExpr(node.left, row);
|
|
176
|
+
const r = evaluateExpr(node.right, row);
|
|
177
|
+
switch (node.op) {
|
|
178
|
+
case '+': return l + r;
|
|
179
|
+
case '-': return l - r;
|
|
180
|
+
case '*': return l * r;
|
|
181
|
+
case '/': return r === 0 ? NaN : l / r;
|
|
182
|
+
}
|
|
183
|
+
return NaN;
|
|
184
|
+
}
|
|
185
|
+
case 'call': {
|
|
186
|
+
const a = node.args.map(arg => evaluateExpr(arg, row));
|
|
187
|
+
switch (node.name) {
|
|
188
|
+
case 'abs': return Math.abs(a[0]);
|
|
189
|
+
case 'round': return Math.round(a[0]);
|
|
190
|
+
case 'floor': return Math.floor(a[0]);
|
|
191
|
+
case 'ceil': return Math.ceil(a[0]);
|
|
192
|
+
case 'sqrt': return Math.sqrt(a[0]);
|
|
193
|
+
case 'min': return Math.min(...a);
|
|
194
|
+
case 'max': return Math.max(...a);
|
|
195
|
+
}
|
|
196
|
+
return NaN;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
23
200
|
// ---------------------------------------------------------------------------
|
|
24
201
|
// DataSnapshotToolLibrary
|
|
25
202
|
// ---------------------------------------------------------------------------
|
|
@@ -58,7 +235,7 @@ let DataSnapshotToolLibrary = class DataSnapshotToolLibrary extends BaseArtifact
|
|
|
58
235
|
},
|
|
59
236
|
{
|
|
60
237
|
name: 'search_rows',
|
|
61
|
-
description: 'Returns rows matching a field condition (max 200 rows). Operators: eq, neq, gt, lt, gte, lte, contains, startsWith.',
|
|
238
|
+
description: 'Returns rows matching a SINGLE field condition (max 200 rows). Operators: eq, neq, gt, lt, gte, lte, contains, startsWith. For multiple conditions (AND/OR), use `filter`.',
|
|
62
239
|
inputSchema: {
|
|
63
240
|
type: 'object',
|
|
64
241
|
properties: {
|
|
@@ -74,11 +251,12 @@ let DataSnapshotToolLibrary = class DataSnapshotToolLibrary extends BaseArtifact
|
|
|
74
251
|
{
|
|
75
252
|
name: 'aggregate',
|
|
76
253
|
description: 'Computes an aggregate over a field. Operations: ' +
|
|
77
|
-
'sum, avg (alias: mean), count, min, max, distinct_count, median, mode, stdev (sample), variance (sample). ' +
|
|
254
|
+
'sum, avg (alias: mean), count, min, max, distinct_count, median, percentile (requires `p`), mode, stdev (sample), variance (sample). ' +
|
|
78
255
|
'Returns an object { value, ...context }. ' +
|
|
79
256
|
'For min/max the object also includes `contributingRows` — rows whose field equals the extremum. ' +
|
|
80
257
|
'For distinct_count, the object includes `distinctValues` — the actual distinct values found. ' +
|
|
81
258
|
'For mode, the object includes `frequency` (how many rows), `contributingRows`, and `allModes` when there is a tie. ' +
|
|
259
|
+
'percentile uses linear interpolation (e.g. p=90 → 90th percentile; median is percentile with p=50). ' +
|
|
82
260
|
'stdev/variance skip null/non-numeric values and use the sample divisor (N-1). ' +
|
|
83
261
|
'Use these context fields directly — do not guess the identity from the scalar alone.',
|
|
84
262
|
inputSchema: {
|
|
@@ -87,10 +265,104 @@ let DataSnapshotToolLibrary = class DataSnapshotToolLibrary extends BaseArtifact
|
|
|
87
265
|
table: { type: 'string' },
|
|
88
266
|
field: { type: 'string' },
|
|
89
267
|
operation: { type: 'string' },
|
|
268
|
+
p: { type: 'number', description: 'Percentile 0-100. Required when operation is "percentile".' },
|
|
90
269
|
},
|
|
91
270
|
required: ['table', 'field', 'operation'],
|
|
92
271
|
},
|
|
93
272
|
},
|
|
273
|
+
{
|
|
274
|
+
name: 'group_aggregate',
|
|
275
|
+
description: 'GROUP BY one or more fields and compute one or more aggregations per group (SQL "GROUP BY ... [HAVING] ... [ORDER BY] ... [LIMIT]"). ' +
|
|
276
|
+
'Use this for rollups like "total revenue by category" or "avg hours by level" — do NOT delegate these to a sub-agent. ' +
|
|
277
|
+
'Each aggregation is { field, operation, alias, p? } where operation is any `aggregate` operation (count may omit field). ' +
|
|
278
|
+
'Returns { groups: [{ <groupBy fields>, <alias>: value, ... }], groupCount }.',
|
|
279
|
+
inputSchema: {
|
|
280
|
+
type: 'object',
|
|
281
|
+
properties: {
|
|
282
|
+
table: { type: 'string' },
|
|
283
|
+
groupBy: { type: 'array', items: { type: 'string' }, description: 'One or more fields to group by.' },
|
|
284
|
+
aggregations: {
|
|
285
|
+
type: 'array',
|
|
286
|
+
description: 'Each: { field, operation, alias, p? }. operation = sum/avg/count/min/max/distinct_count/median/percentile/mode/stdev/variance.',
|
|
287
|
+
items: {
|
|
288
|
+
type: 'object',
|
|
289
|
+
properties: {
|
|
290
|
+
field: { type: 'string' },
|
|
291
|
+
operation: { type: 'string' },
|
|
292
|
+
alias: { type: 'string' },
|
|
293
|
+
p: { type: 'number' },
|
|
294
|
+
},
|
|
295
|
+
required: ['operation', 'alias'],
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
having: {
|
|
299
|
+
type: 'object',
|
|
300
|
+
description: 'Optional post-aggregation filter on an aggregation alias: { alias, operator, value }. Operators as in search_rows.',
|
|
301
|
+
properties: {
|
|
302
|
+
alias: { type: 'string' },
|
|
303
|
+
operator: { type: 'string' },
|
|
304
|
+
value: {},
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
sort: {
|
|
308
|
+
type: 'object',
|
|
309
|
+
description: 'Optional { by, direction }. `by` is a groupBy field or an aggregation alias; direction is "asc" or "desc".',
|
|
310
|
+
properties: {
|
|
311
|
+
by: { type: 'string' },
|
|
312
|
+
direction: { type: 'string' },
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
limit: { type: 'number', description: 'Optional max number of groups to return.' },
|
|
316
|
+
},
|
|
317
|
+
required: ['table', 'groupBy', 'aggregations'],
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
name: 'compute',
|
|
322
|
+
description: 'Adds a derived column from a safe arithmetic expression over numeric fields, then optionally sorts by it and returns the top/bottom N rows. ' +
|
|
323
|
+
'Use this for per-row metrics + ranking like "best price-to-hours ratio" (expression "Price / DurationHours", sort "desc", limit 10) — do NOT delegate these to a sub-agent. ' +
|
|
324
|
+
'Expression supports field names, numbers, + - * /, parentheses, unary minus, and functions: ' + [...EXPR_FUNCTIONS].join(', ') + '. ' +
|
|
325
|
+
'Rows where the expression is undefined (missing/non-numeric field, divide-by-zero) get null for the computed column and sort last. ' +
|
|
326
|
+
'Returns { rows: [original fields + <as>], totalRows, truncated? }.',
|
|
327
|
+
inputSchema: {
|
|
328
|
+
type: 'object',
|
|
329
|
+
properties: {
|
|
330
|
+
table: { type: 'string' },
|
|
331
|
+
expression: { type: 'string', description: 'e.g. "Price / DurationHours" or "(Revenue - Cost) / Revenue".' },
|
|
332
|
+
as: { type: 'string', description: 'Name for the computed column.' },
|
|
333
|
+
sort: { type: 'string', description: 'Optional "asc" or "desc" — sorts by the computed column.' },
|
|
334
|
+
limit: { type: 'number', description: 'Optional max rows to return (top/bottom N after sort).' },
|
|
335
|
+
},
|
|
336
|
+
required: ['table', 'expression', 'as'],
|
|
337
|
+
},
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
name: 'filter',
|
|
341
|
+
description: 'Returns rows matching MULTIPLE field conditions combined with AND/OR (max 200 rows by default). ' +
|
|
342
|
+
'Each condition is { field, operator, value }; operators: eq, neq, gt, lt, gte, lte, contains, startsWith. ' +
|
|
343
|
+
'Use for "Price > 100 AND Level = \'Advanced\'". For a single condition, search_rows is simpler.',
|
|
344
|
+
inputSchema: {
|
|
345
|
+
type: 'object',
|
|
346
|
+
properties: {
|
|
347
|
+
table: { type: 'string' },
|
|
348
|
+
conditions: {
|
|
349
|
+
type: 'array',
|
|
350
|
+
items: {
|
|
351
|
+
type: 'object',
|
|
352
|
+
properties: {
|
|
353
|
+
field: { type: 'string' },
|
|
354
|
+
operator: { type: 'string' },
|
|
355
|
+
value: {},
|
|
356
|
+
},
|
|
357
|
+
required: ['field', 'operator', 'value'],
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
combine: { type: 'string', description: '"and" (default) or "or".' },
|
|
361
|
+
limit: { type: 'number', description: 'Max rows to return. Default: 200' },
|
|
362
|
+
},
|
|
363
|
+
required: ['table', 'conditions'],
|
|
364
|
+
},
|
|
365
|
+
},
|
|
94
366
|
];
|
|
95
367
|
}
|
|
96
368
|
// -----------------------------------------------------------------------
|
|
@@ -125,6 +397,12 @@ let DataSnapshotToolLibrary = class DataSnapshotToolLibrary extends BaseArtifact
|
|
|
125
397
|
return this.handleSearchRows(payload, input);
|
|
126
398
|
case 'aggregate':
|
|
127
399
|
return this.handleAggregate(payload, input);
|
|
400
|
+
case 'group_aggregate':
|
|
401
|
+
return this.handleGroupAggregate(payload, input);
|
|
402
|
+
case 'compute':
|
|
403
|
+
return this.handleCompute(payload, input);
|
|
404
|
+
case 'filter':
|
|
405
|
+
return this.handleFilter(payload, input);
|
|
128
406
|
default:
|
|
129
407
|
return this.errorResult(`Unknown tool: "${toolName}".`);
|
|
130
408
|
}
|
|
@@ -188,9 +466,196 @@ let DataSnapshotToolLibrary = class DataSnapshotToolLibrary extends BaseArtifact
|
|
|
188
466
|
if (!VALID_AGGREGATE_OPS.has(operation)) {
|
|
189
467
|
return this.errorResult(`Unsupported aggregate operation: "${operation}". Valid operations: ${[...VALID_AGGREGATE_OPS].join(', ')}.`);
|
|
190
468
|
}
|
|
191
|
-
|
|
469
|
+
let p;
|
|
470
|
+
if (operation === 'percentile') {
|
|
471
|
+
p = input.p;
|
|
472
|
+
if (typeof p !== 'number' || !Number.isFinite(p) || p < 0 || p > 100) {
|
|
473
|
+
return this.errorResult(`The "percentile" operation requires a numeric "p" between 0 and 100 (got ${JSON.stringify(input.p)}).`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
const result = this.computeAggregate(table.rows, field, operation, p);
|
|
192
477
|
return this.successResult(result);
|
|
193
478
|
}
|
|
479
|
+
handleGroupAggregate(payload, input) {
|
|
480
|
+
const table = this.findTable(payload, input.table);
|
|
481
|
+
if (!table) {
|
|
482
|
+
return this.tableNotFoundError(input.table);
|
|
483
|
+
}
|
|
484
|
+
const groupBy = Array.isArray(input.groupBy)
|
|
485
|
+
? input.groupBy.map(g => String(g))
|
|
486
|
+
: [];
|
|
487
|
+
if (groupBy.length === 0) {
|
|
488
|
+
return this.errorResult('`groupBy` must be a non-empty array of field names.');
|
|
489
|
+
}
|
|
490
|
+
const rawAggs = Array.isArray(input.aggregations) ? input.aggregations : [];
|
|
491
|
+
if (rawAggs.length === 0) {
|
|
492
|
+
return this.errorResult('`aggregations` must be a non-empty array of { field, operation, alias, p? }.');
|
|
493
|
+
}
|
|
494
|
+
const aggs = [];
|
|
495
|
+
for (const a of rawAggs) {
|
|
496
|
+
const operation = String(a.operation ?? '');
|
|
497
|
+
const alias = String(a.alias ?? '');
|
|
498
|
+
const fieldName = a.field !== undefined ? String(a.field) : '';
|
|
499
|
+
if (!VALID_AGGREGATE_OPS.has(operation)) {
|
|
500
|
+
return this.errorResult(`Unsupported aggregate operation "${operation}". Valid operations: ${[...VALID_AGGREGATE_OPS].join(', ')}.`);
|
|
501
|
+
}
|
|
502
|
+
if (!alias) {
|
|
503
|
+
return this.errorResult('Every aggregation requires an "alias".');
|
|
504
|
+
}
|
|
505
|
+
if (operation !== 'count' && !fieldName) {
|
|
506
|
+
return this.errorResult(`Aggregation "${alias}" (operation "${operation}") requires a "field".`);
|
|
507
|
+
}
|
|
508
|
+
let p;
|
|
509
|
+
if (operation === 'percentile') {
|
|
510
|
+
p = a.p;
|
|
511
|
+
if (typeof p !== 'number' || !Number.isFinite(p) || p < 0 || p > 100) {
|
|
512
|
+
return this.errorResult(`Aggregation "${alias}" uses "percentile" and requires a numeric "p" between 0 and 100.`);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
aggs.push({ field: fieldName, operation: operation, alias, p });
|
|
516
|
+
}
|
|
517
|
+
// Bucket rows by composite group key.
|
|
518
|
+
const buckets = new Map();
|
|
519
|
+
for (const row of table.rows) {
|
|
520
|
+
const keyValues = {};
|
|
521
|
+
for (const g of groupBy)
|
|
522
|
+
keyValues[g] = row[g];
|
|
523
|
+
const key = JSON.stringify(groupBy.map(g => row[g] ?? null));
|
|
524
|
+
let bucket = buckets.get(key);
|
|
525
|
+
if (!bucket) {
|
|
526
|
+
bucket = { keyValues, rows: [] };
|
|
527
|
+
buckets.set(key, bucket);
|
|
528
|
+
}
|
|
529
|
+
bucket.rows.push(row);
|
|
530
|
+
}
|
|
531
|
+
// Build group rows: group-by values + each aggregation's scalar value.
|
|
532
|
+
let groups = [];
|
|
533
|
+
for (const bucket of buckets.values()) {
|
|
534
|
+
const groupRow = { ...bucket.keyValues };
|
|
535
|
+
for (const agg of aggs) {
|
|
536
|
+
groupRow[agg.alias] = this.computeAggregate(bucket.rows, agg.field, agg.operation, agg.p).value;
|
|
537
|
+
}
|
|
538
|
+
groups.push(groupRow);
|
|
539
|
+
}
|
|
540
|
+
// Optional HAVING filter on an aggregation alias.
|
|
541
|
+
if (input.having && typeof input.having === 'object') {
|
|
542
|
+
const having = input.having;
|
|
543
|
+
const alias = String(having.alias ?? '');
|
|
544
|
+
const operator = String(having.operator ?? '');
|
|
545
|
+
if (!alias || !VALID_SEARCH_OPERATORS.has(operator)) {
|
|
546
|
+
return this.errorResult('`having` must be { alias, operator, value } with a valid operator.');
|
|
547
|
+
}
|
|
548
|
+
groups = groups.filter(g => this.evaluateCondition(g[alias], operator, having.value));
|
|
549
|
+
}
|
|
550
|
+
// Optional sort by a group-by field or an aggregation alias.
|
|
551
|
+
if (input.sort && typeof input.sort === 'object') {
|
|
552
|
+
const sort = input.sort;
|
|
553
|
+
const by = String(sort.by ?? '');
|
|
554
|
+
const direction = String(sort.direction ?? 'asc').toLowerCase() === 'desc' ? 'desc' : 'asc';
|
|
555
|
+
if (by)
|
|
556
|
+
groups = this.sortRows(groups, by, direction);
|
|
557
|
+
}
|
|
558
|
+
const groupCount = groups.length;
|
|
559
|
+
const limit = input.limit;
|
|
560
|
+
const truncated = typeof limit === 'number' && limit >= 0 && groupCount > limit;
|
|
561
|
+
if (truncated)
|
|
562
|
+
groups = groups.slice(0, limit);
|
|
563
|
+
return this.successResult({
|
|
564
|
+
groups,
|
|
565
|
+
groupCount,
|
|
566
|
+
...(truncated ? { truncated: true } : {}),
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
handleCompute(payload, input) {
|
|
570
|
+
const table = this.findTable(payload, input.table);
|
|
571
|
+
if (!table) {
|
|
572
|
+
return this.tableNotFoundError(input.table);
|
|
573
|
+
}
|
|
574
|
+
const expression = input.expression;
|
|
575
|
+
const as = input.as;
|
|
576
|
+
if (typeof expression !== 'string' || expression.trim().length === 0) {
|
|
577
|
+
return this.errorResult('`expression` is required (e.g. "Price / DurationHours").');
|
|
578
|
+
}
|
|
579
|
+
if (typeof as !== 'string' || as.trim().length === 0) {
|
|
580
|
+
return this.errorResult('`as` (name for the computed column) is required.');
|
|
581
|
+
}
|
|
582
|
+
let ast;
|
|
583
|
+
try {
|
|
584
|
+
ast = new ExpressionParser().parse(expression);
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
588
|
+
return this.errorResult(`Could not parse expression "${expression}": ${msg}`);
|
|
589
|
+
}
|
|
590
|
+
// Compute the derived column per row (NaN/non-finite → null).
|
|
591
|
+
const computed = table.rows.map(row => {
|
|
592
|
+
const raw = evaluateExpr(ast, row);
|
|
593
|
+
const value = Number.isFinite(raw) ? raw : null;
|
|
594
|
+
return { ...row, [as]: value };
|
|
595
|
+
});
|
|
596
|
+
// Optional sort by the computed column; nulls always sort last.
|
|
597
|
+
let rows = computed;
|
|
598
|
+
const sortRaw = typeof input.sort === 'string' ? input.sort.toLowerCase() : undefined;
|
|
599
|
+
if (sortRaw === 'asc' || sortRaw === 'desc') {
|
|
600
|
+
rows = [...computed].sort((a, b) => {
|
|
601
|
+
const av = a[as];
|
|
602
|
+
const bv = b[as];
|
|
603
|
+
if (av === null && bv === null)
|
|
604
|
+
return 0;
|
|
605
|
+
if (av === null)
|
|
606
|
+
return 1;
|
|
607
|
+
if (bv === null)
|
|
608
|
+
return -1;
|
|
609
|
+
return sortRaw === 'asc' ? av - bv : bv - av;
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
const totalRows = rows.length;
|
|
613
|
+
const limit = input.limit;
|
|
614
|
+
const truncated = typeof limit === 'number' && limit >= 0 && totalRows > limit;
|
|
615
|
+
if (truncated)
|
|
616
|
+
rows = rows.slice(0, limit);
|
|
617
|
+
return this.successResult({
|
|
618
|
+
rows,
|
|
619
|
+
totalRows,
|
|
620
|
+
...(truncated ? { truncated: true } : {}),
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
handleFilter(payload, input) {
|
|
624
|
+
const table = this.findTable(payload, input.table);
|
|
625
|
+
if (!table) {
|
|
626
|
+
return this.tableNotFoundError(input.table);
|
|
627
|
+
}
|
|
628
|
+
const rawConditions = Array.isArray(input.conditions) ? input.conditions : [];
|
|
629
|
+
if (rawConditions.length === 0) {
|
|
630
|
+
return this.errorResult('`conditions` must be a non-empty array of { field, operator, value }.');
|
|
631
|
+
}
|
|
632
|
+
const conditions = [];
|
|
633
|
+
for (const c of rawConditions) {
|
|
634
|
+
const field = String(c.field ?? '');
|
|
635
|
+
const operator = String(c.operator ?? '');
|
|
636
|
+
if (!field || !VALID_SEARCH_OPERATORS.has(operator)) {
|
|
637
|
+
return this.errorResult(`Each condition needs a "field" and a valid "operator" (${[...VALID_SEARCH_OPERATORS].join(', ')}). Got field="${field}", operator="${operator}".`);
|
|
638
|
+
}
|
|
639
|
+
conditions.push({ field, operator: operator, value: c.value });
|
|
640
|
+
}
|
|
641
|
+
const combine = String(input.combine ?? 'and').toLowerCase() === 'or' ? 'or' : 'and';
|
|
642
|
+
const limit = Math.min(input.limit ?? 200, 500);
|
|
643
|
+
const matches = (row) => {
|
|
644
|
+
if (combine === 'or') {
|
|
645
|
+
return conditions.some(c => this.evaluateCondition(row[c.field], c.operator, c.value));
|
|
646
|
+
}
|
|
647
|
+
return conditions.every(c => this.evaluateCondition(row[c.field], c.operator, c.value));
|
|
648
|
+
};
|
|
649
|
+
const allMatched = table.rows.filter(matches);
|
|
650
|
+
const truncated = allMatched.length > limit;
|
|
651
|
+
const rows = truncated ? allMatched.slice(0, limit) : allMatched;
|
|
652
|
+
return this.successResult({
|
|
653
|
+
rows,
|
|
654
|
+
totalMatches: allMatched.length,
|
|
655
|
+
combine,
|
|
656
|
+
...(truncated ? { truncated: true, note: `Showing ${limit} of ${allMatched.length} matches. Use the 'limit' parameter or narrow your conditions.` } : {}),
|
|
657
|
+
});
|
|
658
|
+
}
|
|
194
659
|
// -----------------------------------------------------------------------
|
|
195
660
|
// Search condition evaluator
|
|
196
661
|
// -----------------------------------------------------------------------
|
|
@@ -215,9 +680,32 @@ let DataSnapshotToolLibrary = class DataSnapshotToolLibrary extends BaseArtifact
|
|
|
215
680
|
}
|
|
216
681
|
}
|
|
217
682
|
// -----------------------------------------------------------------------
|
|
683
|
+
// Sort helper (shared by group_aggregate)
|
|
684
|
+
// -----------------------------------------------------------------------
|
|
685
|
+
sortRows(rows, by, direction) {
|
|
686
|
+
return [...rows].sort((a, b) => {
|
|
687
|
+
const av = a[by];
|
|
688
|
+
const bv = b[by];
|
|
689
|
+
// Nulls/undefined last regardless of direction.
|
|
690
|
+
const aNull = av === null || av === undefined;
|
|
691
|
+
const bNull = bv === null || bv === undefined;
|
|
692
|
+
if (aNull && bNull)
|
|
693
|
+
return 0;
|
|
694
|
+
if (aNull)
|
|
695
|
+
return 1;
|
|
696
|
+
if (bNull)
|
|
697
|
+
return -1;
|
|
698
|
+
if (typeof av === 'number' && typeof bv === 'number') {
|
|
699
|
+
return direction === 'asc' ? av - bv : bv - av;
|
|
700
|
+
}
|
|
701
|
+
const cmp = String(av).localeCompare(String(bv));
|
|
702
|
+
return direction === 'asc' ? cmp : -cmp;
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
// -----------------------------------------------------------------------
|
|
218
706
|
// Aggregate computation
|
|
219
707
|
// -----------------------------------------------------------------------
|
|
220
|
-
computeAggregate(rows, field, operation) {
|
|
708
|
+
computeAggregate(rows, field, operation, p) {
|
|
221
709
|
const MAX_CONTRIBUTING_ROWS = 10;
|
|
222
710
|
const MAX_DISTINCT_VALUES = 50;
|
|
223
711
|
// Helper — extract finite numeric values for numeric-only operations.
|
|
@@ -260,16 +748,23 @@ let DataSnapshotToolLibrary = class DataSnapshotToolLibrary extends BaseArtifact
|
|
|
260
748
|
result.truncated = true;
|
|
261
749
|
return result;
|
|
262
750
|
}
|
|
263
|
-
case 'median':
|
|
751
|
+
case 'median':
|
|
752
|
+
case 'percentile': {
|
|
753
|
+
// median is percentile with p=50. Both use linear interpolation
|
|
754
|
+
// between the two closest ranks (matches the prior median impl:
|
|
755
|
+
// odd N → middle value, even N → mean of the two middle values).
|
|
264
756
|
const nums = numericValues();
|
|
265
757
|
if (nums.length === 0)
|
|
266
758
|
return { value: 0 };
|
|
267
759
|
nums.sort((a, b) => a - b);
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
760
|
+
if (nums.length === 1)
|
|
761
|
+
return { value: nums[0] };
|
|
762
|
+
const pp = operation === 'median' ? 50 : (p ?? 50);
|
|
763
|
+
const rank = (pp / 100) * (nums.length - 1);
|
|
764
|
+
const lo = Math.floor(rank);
|
|
765
|
+
const hi = Math.ceil(rank);
|
|
766
|
+
const value = nums[lo] + (nums[hi] - nums[lo]) * (rank - lo);
|
|
767
|
+
return { value };
|
|
273
768
|
}
|
|
274
769
|
case 'mode': {
|
|
275
770
|
if (rows.length === 0)
|