@genesislcap/ai-assistant 15.34.1 → 15.35.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/dist/custom-elements.json +449 -0
- package/dist/dts/genesis/config.d.ts +22 -0
- package/dist/dts/genesis/config.d.ts.map +1 -0
- package/dist/dts/genesis/criteria.d.ts +83 -0
- package/dist/dts/genesis/criteria.d.ts.map +1 -0
- package/dist/dts/genesis/filter-fields.d.ts +62 -0
- package/dist/dts/genesis/filter-fields.d.ts.map +1 -0
- package/dist/dts/genesis/index.d.ts +19 -0
- package/dist/dts/genesis/index.d.ts.map +1 -0
- package/dist/dts/genesis/register-genesis-assistant.d.ts +52 -0
- package/dist/dts/genesis/register-genesis-assistant.d.ts.map +1 -0
- package/dist/dts/genesis/resource-tools.d.ts +86 -0
- package/dist/dts/genesis/resource-tools.d.ts.map +1 -0
- package/dist/dts/genesis/types.d.ts +115 -0
- package/dist/dts/genesis/types.d.ts.map +1 -0
- package/dist/esm/genesis/config.js +86 -0
- package/dist/esm/genesis/criteria.js +426 -0
- package/dist/esm/genesis/filter-fields.js +124 -0
- package/dist/esm/genesis/index.js +15 -0
- package/dist/esm/genesis/register-genesis-assistant.js +160 -0
- package/dist/esm/genesis/resource-tools.js +378 -0
- package/dist/esm/genesis/types.js +6 -0
- package/docs/genesis.md +180 -0
- package/package.json +32 -16
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Criteria composition for the Genesis read tools.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* **The model never writes a criteria expression, and this is the reason.** Criteria are parsed
|
|
6
|
+
* as Groovy on the server, and the platform composes an app's row-level restriction into the
|
|
7
|
+
* same string: `"($userCriteria) && ($criteriaTemplate)"`. A criteria string authored from model
|
|
8
|
+
* output could therefore `||` its way past the restriction that decides which rows a user is
|
|
9
|
+
* allowed to see. So the tool takes STRUCTURED filters — field, op, value — validated against
|
|
10
|
+
* the fields the platform itself reports as filterable, and this module builds the expression.
|
|
11
|
+
* Anything it cannot build is refused outright: never a partial expression, never a silent drop.
|
|
12
|
+
*
|
|
13
|
+
* Do not "simplify" this into a free-text criteria parameter.
|
|
14
|
+
*
|
|
15
|
+
* The grammar and escaping match the platform's other structured-filter caller, grid-pro's
|
|
16
|
+
* `filter.utils.ts`, so the bridge is not a second dialect of the same expression language.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Genesis field types, EXACTLY matched — copied from
|
|
20
|
+
* `foundation-ui/src/ai-criteria-search/validation/schema-validator.ts`, which puts the reason
|
|
21
|
+
* plainly: "exact match to avoid false positives (e.g. STATUS containing INT)". A prefix test
|
|
22
|
+
* also mistakes `NANO_TIMESTAMP` and `LOCALDATETIME` for text, which quotes a string against a
|
|
23
|
+
* timestamp column and silently returns nothing. The sets are module-private there, so they are
|
|
24
|
+
* copied rather than imported, and pinned by a test.
|
|
25
|
+
*/
|
|
26
|
+
const DATE_TYPES = new Set(['DATE', 'LOCALDATE']);
|
|
27
|
+
const DATETIME_TYPES = new Set(['DATETIME', 'NANO_TIMESTAMP', 'LOCALDATETIME', 'TIMESTAMP']);
|
|
28
|
+
const NUMBER_TYPES = new Set([
|
|
29
|
+
'INT',
|
|
30
|
+
'INTEGER',
|
|
31
|
+
'LONG',
|
|
32
|
+
'SHORT',
|
|
33
|
+
'DOUBLE',
|
|
34
|
+
'BIGDECIMAL',
|
|
35
|
+
'DECIMAL',
|
|
36
|
+
]);
|
|
37
|
+
/** `STRING(256)` and `BIGDECIMAL(10,3)` carry their parameters; the set holds the bare name. */
|
|
38
|
+
const normaliseType = (fieldType) => String(fieldType !== null && fieldType !== void 0 ? fieldType : '')
|
|
39
|
+
.toUpperCase()
|
|
40
|
+
.trim()
|
|
41
|
+
.split('(')[0]
|
|
42
|
+
.trim();
|
|
43
|
+
const OPS_BY_KIND = {
|
|
44
|
+
string: ['equals', 'not_equals', 'contains', 'is_blank', 'is_not_blank'],
|
|
45
|
+
number: [
|
|
46
|
+
'equals',
|
|
47
|
+
'not_equals',
|
|
48
|
+
'greater_than',
|
|
49
|
+
'greater_or_equal',
|
|
50
|
+
'less_than',
|
|
51
|
+
'less_or_equal',
|
|
52
|
+
'is_blank',
|
|
53
|
+
'is_not_blank',
|
|
54
|
+
],
|
|
55
|
+
boolean: ['equals', 'not_equals', 'is_blank', 'is_not_blank'],
|
|
56
|
+
date: [
|
|
57
|
+
'equals',
|
|
58
|
+
'greater_than',
|
|
59
|
+
'greater_or_equal',
|
|
60
|
+
'less_than',
|
|
61
|
+
'less_or_equal',
|
|
62
|
+
'is_blank',
|
|
63
|
+
'is_not_blank',
|
|
64
|
+
],
|
|
65
|
+
// No equality: a timestamp is almost never exactly equal to a value a model would write, and
|
|
66
|
+
// the platform's own datetime operator list offers none either (foundation-ui operator-map).
|
|
67
|
+
datetime: ['greater_or_equal', 'less_or_equal', 'is_blank', 'is_not_blank'],
|
|
68
|
+
};
|
|
69
|
+
/** @internal */
|
|
70
|
+
export function fieldKindOf(type) {
|
|
71
|
+
const normalised = normaliseType(type);
|
|
72
|
+
if (DATE_TYPES.has(normalised))
|
|
73
|
+
return 'date';
|
|
74
|
+
if (DATETIME_TYPES.has(normalised))
|
|
75
|
+
return 'datetime';
|
|
76
|
+
if (NUMBER_TYPES.has(normalised))
|
|
77
|
+
return 'number';
|
|
78
|
+
if (normalised === 'BOOLEAN')
|
|
79
|
+
return 'boolean';
|
|
80
|
+
return 'string';
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Escapes a value for a criteria string literal: backslashes first, then quotes. An unescaped
|
|
84
|
+
* quote would close the literal early — which is the injection this module exists to prevent —
|
|
85
|
+
* and a trailing backslash would escape the closing quote instead.
|
|
86
|
+
*/
|
|
87
|
+
const escape = (value) => String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
88
|
+
/**
|
|
89
|
+
* The inclusive range each part of a date or time bound may take. A part outside it reaches the
|
|
90
|
+
* server as a criteria it cannot evaluate, and the read path deliberately hides the compiler's
|
|
91
|
+
* answer from the model — so the explanation is produced here or nowhere.
|
|
92
|
+
*/
|
|
93
|
+
const BOUND_LIMITS = {
|
|
94
|
+
month: { min: 1, max: 12 },
|
|
95
|
+
day: { min: 1, max: 31 },
|
|
96
|
+
hour: { min: 0, max: 23 },
|
|
97
|
+
minute: { min: 0, max: 59 },
|
|
98
|
+
second: { min: 0, max: 59 },
|
|
99
|
+
};
|
|
100
|
+
/** Whether a captured part is within its limits. An absent part is not out of range. */
|
|
101
|
+
const inRange = (text, part) => {
|
|
102
|
+
if (text === undefined)
|
|
103
|
+
return true;
|
|
104
|
+
const { min, max } = BOUND_LIMITS[part];
|
|
105
|
+
const value = Number(text);
|
|
106
|
+
return value >= min && value <= max;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* `2026-09-21` to the `yyyyMMdd` form the platform's `Expr.dateIs*` helpers take. Anchored at
|
|
110
|
+
* both ends: trailing characters mean the model meant something this does not handle, and
|
|
111
|
+
* silently keeping the first eight digits would filter on a date nobody asked for.
|
|
112
|
+
*
|
|
113
|
+
* The month and day are range-checked because an impossible one reaches the server as a criteria
|
|
114
|
+
* it cannot evaluate, and the model is then told "that filter was not accepted by the app" with
|
|
115
|
+
* no way to tell which part of it was wrong — the read path deliberately hides the compiler's
|
|
116
|
+
* answer, so the explanation has to be produced here or nowhere.
|
|
117
|
+
*/
|
|
118
|
+
function toPlatformDate(value) {
|
|
119
|
+
const match = /^(\d{4})-?(\d{2})-?(\d{2})$/.exec(String(value).trim());
|
|
120
|
+
if (!match || !inRange(match[2], 'month') || !inRange(match[3], 'day'))
|
|
121
|
+
return undefined;
|
|
122
|
+
return `${match[1]}${match[2]}${match[3]}`;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The `yyyyMMdd-HH:mm:ss` bound the `Expr.dateTimeIs*` helpers take, filled out to the END of
|
|
126
|
+
* whatever precision the model expressed for an upper bound and to its START for a lower one.
|
|
127
|
+
*
|
|
128
|
+
* @remarks
|
|
129
|
+
* **Defaulting both bounds to `00:00` loses a whole day, silently.** `CREATED_AT less_or_equal
|
|
130
|
+
* '2026-09-21'` composed `Expr.dateTimeIsLessEqual(CREATED_AT, '20260921-00:00')`, which is
|
|
131
|
+
* valid Groovy, compiles, and matches only a row stored exactly at midnight — so "on or before
|
|
132
|
+
* the 21st" returned almost nothing and reported it as an empty result. Reviewer catch on #2543.
|
|
133
|
+
*
|
|
134
|
+
* One rule covers every precision rather than special-casing the bare date: an inclusive upper
|
|
135
|
+
* bound runs to the end of the unit the model named (a day, a minute, a second) and a lower
|
|
136
|
+
* bound to its start. A supplied time is never widened — `'2026-09-21T14:30'` as an upper bound
|
|
137
|
+
* means through 14:30:59, not through the end of the day.
|
|
138
|
+
*
|
|
139
|
+
* **Seconds, not milliseconds.** `foundation-ui`'s own date filter emits `'20211013-00:00:00'`
|
|
140
|
+
* into these helpers (`filter.ts`, `toISOString().slice(0, 19)`), so seconds are the precision
|
|
141
|
+
* proven to work; nothing in the monorepo sends millis, and a bound shape the parser does not
|
|
142
|
+
* take fails a real user's read rather than a test. The residual gap is therefore under one
|
|
143
|
+
* second at the top of a range, and closing it needs one live capture of a `.SSS` bound.
|
|
144
|
+
*
|
|
145
|
+
* Milliseconds on the way IN are refused rather than truncated: dropping them narrows an upper
|
|
146
|
+
* bound and widens a lower one, which is the same silent-day bug one unit down.
|
|
147
|
+
*/
|
|
148
|
+
function toPlatformDateTime(value, bound) {
|
|
149
|
+
const match = /^(\d{4})-?(\d{2})-?(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/.exec(String(value).trim());
|
|
150
|
+
if (!match)
|
|
151
|
+
return undefined;
|
|
152
|
+
const [, year, month, day, hour, minute, second] = match;
|
|
153
|
+
if (!inRange(month, 'month') || !inRange(day, 'day'))
|
|
154
|
+
return undefined;
|
|
155
|
+
if (!inRange(hour, 'hour') || !inRange(minute, 'minute') || !inRange(second, 'second')) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
const fill = bound === 'end';
|
|
159
|
+
return (`${year}${month}${day}-${hour !== null && hour !== void 0 ? hour : (fill ? '23' : '00')}` +
|
|
160
|
+
`:${minute !== null && minute !== void 0 ? minute : (fill ? '59' : '00')}:${second !== null && second !== void 0 ? second : (fill ? '59' : '00')}`);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Each fragment builder answers only for the ops it knows, and `undefined` otherwise.
|
|
164
|
+
*
|
|
165
|
+
* @remarks
|
|
166
|
+
* These used to end in a `default:` that returned one operator — `containsIgnoreCase` for a
|
|
167
|
+
* string, `dateIsLessEqual` for a date — so an op added to {@link OPS_BY_KIND} without a fragment
|
|
168
|
+
* would have been composed silently as that one, filtering on something nobody asked for. The
|
|
169
|
+
* allow-list makes it unreachable today; this makes it impossible tomorrow, which is the same
|
|
170
|
+
* argument the operator Map is built on. Exported so that property can be tested at all — the
|
|
171
|
+
* allow-list is what stops `composeCriteria` reaching it.
|
|
172
|
+
*
|
|
173
|
+
* @internal
|
|
174
|
+
*/
|
|
175
|
+
export function stringFragment(field, op, value) {
|
|
176
|
+
switch (op) {
|
|
177
|
+
case 'equals':
|
|
178
|
+
return `${field} == '${escape(value)}'`;
|
|
179
|
+
case 'not_equals':
|
|
180
|
+
return `${field} != '${escape(value)}'`;
|
|
181
|
+
case 'contains':
|
|
182
|
+
return `Expr.containsIgnoreCase(${field}, '${escape(value)}')`;
|
|
183
|
+
default:
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// A Map, not an object: this is the one position where model input chooses an OPERATOR, and a
|
|
188
|
+
// bare lookup would find `constructor` and friends on Object.prototype. The op allow-list above
|
|
189
|
+
// already blocks that; this makes it impossible rather than merely unreachable.
|
|
190
|
+
const NUMBER_OPERATORS = new Map([
|
|
191
|
+
['equals', '=='],
|
|
192
|
+
['not_equals', '!='],
|
|
193
|
+
['greater_than', '>'],
|
|
194
|
+
['greater_or_equal', '>='],
|
|
195
|
+
['less_than', '<'],
|
|
196
|
+
['less_or_equal', '<='],
|
|
197
|
+
]);
|
|
198
|
+
export function dateFragment(field, op, date) {
|
|
199
|
+
switch (op) {
|
|
200
|
+
case 'equals':
|
|
201
|
+
return `Expr.dateIsEqual(${field}, '${date}')`;
|
|
202
|
+
case 'greater_than':
|
|
203
|
+
return `Expr.dateIsAfter(${field}, '${date}')`;
|
|
204
|
+
case 'less_than':
|
|
205
|
+
return `Expr.dateIsBefore(${field}, '${date}')`;
|
|
206
|
+
case 'greater_or_equal':
|
|
207
|
+
return `Expr.dateIsGreaterEqual(${field}, '${date}')`;
|
|
208
|
+
case 'less_or_equal':
|
|
209
|
+
return `Expr.dateIsLessEqual(${field}, '${date}')`;
|
|
210
|
+
default:
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Timestamp columns get the dateTime helpers, which take a `yyyyMMdd-HH:mm:ss` bound.
|
|
216
|
+
*
|
|
217
|
+
* @remarks
|
|
218
|
+
* There is no strictly-before helper to reach for: `dateTimeIsBefore` maps to
|
|
219
|
+
* `dateTimeIsLessEqual` (foundation-ui's operator-map), and a negated
|
|
220
|
+
* `!Expr.dateTimeIsGreaterEqual(...)` would pull rows with a NULL timestamp INTO the match set,
|
|
221
|
+
* because the comparison is false for null. So the bound itself carries the inclusivity — see
|
|
222
|
+
* {@link toPlatformDateTime}.
|
|
223
|
+
*/
|
|
224
|
+
export function dateTimeFragment(field, op, bound) {
|
|
225
|
+
if (op === 'greater_or_equal')
|
|
226
|
+
return `Expr.dateTimeIsGreaterEqual(${field}, '${bound}')`;
|
|
227
|
+
return op === 'less_or_equal' ? `Expr.dateTimeIsLessEqual(${field}, '${bound}')` : undefined;
|
|
228
|
+
}
|
|
229
|
+
/** A value being absent is not the same as being empty, except for strings, where both count. */
|
|
230
|
+
function blankFragment(field, kind, negated) {
|
|
231
|
+
if (kind !== 'string')
|
|
232
|
+
return `${field} ${negated ? '!=' : '=='} null`;
|
|
233
|
+
return negated ? `(${field} != '' && ${field} != null)` : `(${field} == '' || ${field} == null)`;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* What one filter value, and one whole expression, may cost.
|
|
237
|
+
*
|
|
238
|
+
* @remarks
|
|
239
|
+
* Bounding the NUMBER of filters is not the bound that matters: one filter with a 200,000
|
|
240
|
+
* character value composes a 200KB expression, and the number path accepts it by design — the
|
|
241
|
+
* digits-only pattern carries them through verbatim precisely so a BIGDECIMAL beyond double
|
|
242
|
+
* precision is not rounded, and `'9'.repeat(200000)` is all digits.
|
|
243
|
+
*
|
|
244
|
+
* The expensive part is not the server's. A rejection reason echoes the offending value, so a
|
|
245
|
+
* 200KB value produced a 200KB reason, which lands in the tool result, is stringified into the
|
|
246
|
+
* transcript, and is billed again as input on every later turn of the conversation — from the
|
|
247
|
+
* path that is supposed to be the safe one. Rows are stamped untrusted because other users
|
|
248
|
+
* wrote them, so a row carrying text that steers the model into copying a long string is a real
|
|
249
|
+
* route in. Cost and stability rather than privilege: the grammar holds either way.
|
|
250
|
+
*
|
|
251
|
+
* So the length is bounded, and checked BEFORE any rejection that interpolates the value.
|
|
252
|
+
* Reviewer catch on #2543, with a different quantity: his `maxItems` bounds the count, which
|
|
253
|
+
* permits ten times the expression he demonstrated.
|
|
254
|
+
*/
|
|
255
|
+
const MAX_VALUE_LENGTH = 512;
|
|
256
|
+
const MAX_CRITERIA_LENGTH = 4096;
|
|
257
|
+
/** Everything below SPACE, and DEL: the characters an expression cannot carry. */
|
|
258
|
+
const FIRST_PRINTABLE_CODE = 0x20;
|
|
259
|
+
const DELETE_CODE = 0x7f;
|
|
260
|
+
/** A line break or other control character — checked by code point, not by a control regex. */
|
|
261
|
+
function hasControlCharacter(text) {
|
|
262
|
+
for (const character of text) {
|
|
263
|
+
const code = character.codePointAt(0);
|
|
264
|
+
if (code !== undefined && (code < FIRST_PRINTABLE_CODE || code === DELETE_CODE))
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* A fragment, or a refusal if the builder had no expression for that op — which can only happen
|
|
271
|
+
* if an op joins {@link OPS_BY_KIND} without one, and is a bug here rather than a bad filter.
|
|
272
|
+
*/
|
|
273
|
+
function composed(field, op, criteria) {
|
|
274
|
+
return criteria
|
|
275
|
+
? { status: 'ok', criteria }
|
|
276
|
+
: { status: 'rejected', reason: `'${op}' cannot be applied to ${field.name}.` };
|
|
277
|
+
}
|
|
278
|
+
function fragmentFor(filter, field) {
|
|
279
|
+
var _a;
|
|
280
|
+
const kind = fieldKindOf(field.type);
|
|
281
|
+
const allowed = OPS_BY_KIND[kind];
|
|
282
|
+
if (!allowed.includes(filter.op)) {
|
|
283
|
+
return {
|
|
284
|
+
status: 'rejected',
|
|
285
|
+
reason: `${field.name} is a ${kind} field, so '${filter.op}' does not apply to it. Use one of: ${allowed.join(', ')}.`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
if (filter.op === 'is_blank' || filter.op === 'is_not_blank') {
|
|
289
|
+
return {
|
|
290
|
+
status: 'ok',
|
|
291
|
+
criteria: blankFragment(field.name, kind, filter.op === 'is_not_blank'),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
if (filter.value === undefined || filter.value === null || filter.value === '') {
|
|
295
|
+
return { status: 'rejected', reason: `the filter on ${field.name} has no value.` };
|
|
296
|
+
}
|
|
297
|
+
// Before the checks below, every one of which interpolates the value into its reason: a guard
|
|
298
|
+
// placed after them leaves the amplification it exists to prevent wide open. The reason here
|
|
299
|
+
// names the LENGTH and never the value, for the same reason.
|
|
300
|
+
const length = String(filter.value).length;
|
|
301
|
+
if (length > MAX_VALUE_LENGTH) {
|
|
302
|
+
return {
|
|
303
|
+
status: 'rejected',
|
|
304
|
+
reason: `the value for ${field.name} is ${length} characters. A filter value may be up to ${MAX_VALUE_LENGTH}.`,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
if ((_a = field.validValues) === null || _a === void 0 ? void 0 : _a.length) {
|
|
308
|
+
const value = String(filter.value);
|
|
309
|
+
if (!field.validValues.includes(value)) {
|
|
310
|
+
return {
|
|
311
|
+
status: 'rejected',
|
|
312
|
+
reason: `${value} is not a value ${field.name} can hold. It is one of: ${field.validValues.join(', ')}.`,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
// A newline or other control character escapes cleanly but leaves an expression the server's
|
|
317
|
+
// Groovy parser rejects, so the read fails there instead of here, with a compiler error the
|
|
318
|
+
// model cannot act on. Refused with a reason it can.
|
|
319
|
+
if (typeof filter.value === 'string' && hasControlCharacter(filter.value)) {
|
|
320
|
+
return {
|
|
321
|
+
status: 'rejected',
|
|
322
|
+
reason: `the value for ${field.name} contains a line break or control character.`,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
switch (kind) {
|
|
326
|
+
case 'number': {
|
|
327
|
+
// The digits are carried through verbatim rather than via Number(): a LONG or BIGDECIMAL
|
|
328
|
+
// beyond double precision would otherwise be rounded INTO the expression, and the model
|
|
329
|
+
// would be told about rows that were never matched. The pattern is the validation — only
|
|
330
|
+
// digits, one optional sign and one optional point reach the expression.
|
|
331
|
+
const text = String(filter.value).trim();
|
|
332
|
+
if (!/^[-+]?\d+(?:\.\d+)?$/.test(text)) {
|
|
333
|
+
return {
|
|
334
|
+
status: 'rejected',
|
|
335
|
+
reason: `${field.name} is a number, and '${String(filter.value)}' is not one.`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return { status: 'ok', criteria: `${field.name} ${NUMBER_OPERATORS.get(filter.op)} ${text}` };
|
|
339
|
+
}
|
|
340
|
+
case 'boolean': {
|
|
341
|
+
const text = String(filter.value).toLowerCase();
|
|
342
|
+
if (text !== 'true' && text !== 'false') {
|
|
343
|
+
return {
|
|
344
|
+
status: 'rejected',
|
|
345
|
+
reason: `${field.name} is true or false, and '${String(filter.value)}' is neither.`,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
status: 'ok',
|
|
350
|
+
criteria: `${field.name} ${filter.op === 'equals' ? '==' : '!='} ${text}`,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
case 'datetime': {
|
|
354
|
+
// An inclusive upper bound runs to the end of the unit the model named; a lower bound to
|
|
355
|
+
// its start. `less_or_equal` is the only upper-bound op a timestamp takes.
|
|
356
|
+
const bound = toPlatformDateTime(filter.value, filter.op === 'less_or_equal' ? 'end' : 'start');
|
|
357
|
+
if (!bound) {
|
|
358
|
+
return {
|
|
359
|
+
status: 'rejected',
|
|
360
|
+
reason: `${field.name} is a timestamp, and '${String(filter.value)}' is not one. Use YYYY-MM-DD, YYYY-MM-DDTHH:MM or YYYY-MM-DDTHH:MM:SS.`,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
return composed(field, filter.op, dateTimeFragment(field.name, filter.op, bound));
|
|
364
|
+
}
|
|
365
|
+
case 'date': {
|
|
366
|
+
const date = toPlatformDate(filter.value);
|
|
367
|
+
if (!date) {
|
|
368
|
+
return {
|
|
369
|
+
status: 'rejected',
|
|
370
|
+
reason: `${field.name} is a date, and '${String(filter.value)}' is not one. Use YYYY-MM-DD.`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
return composed(field, filter.op, dateFragment(field.name, filter.op, date));
|
|
374
|
+
}
|
|
375
|
+
default:
|
|
376
|
+
return composed(field, filter.op, stringFragment(field.name, filter.op, filter.value));
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Builds one `CRITERIA_MATCH` expression from structured filters, or refuses.
|
|
381
|
+
*
|
|
382
|
+
* @remarks
|
|
383
|
+
* Fragments are parenthesised before they are joined with `&&`, because `&&` binds tighter than
|
|
384
|
+
* `||`: an unparenthesised `a == '' || a == null` fragment would otherwise let rows through that
|
|
385
|
+
* the following filters exclude.
|
|
386
|
+
*
|
|
387
|
+
* @internal
|
|
388
|
+
*/
|
|
389
|
+
export function composeCriteria(filters, fields) {
|
|
390
|
+
if (filters === undefined || filters === null)
|
|
391
|
+
return { status: 'ok' };
|
|
392
|
+
if (!Array.isArray(filters)) {
|
|
393
|
+
return { status: 'rejected', reason: 'filters must be a list of {field, op, value} entries.' };
|
|
394
|
+
}
|
|
395
|
+
const fragments = [];
|
|
396
|
+
for (const entry of filters) {
|
|
397
|
+
const filter = entry;
|
|
398
|
+
if (!filter || typeof filter.field !== 'string') {
|
|
399
|
+
return { status: 'rejected', reason: 'every filter needs a field name.' };
|
|
400
|
+
}
|
|
401
|
+
const field = fields.get(filter.field);
|
|
402
|
+
if (!field) {
|
|
403
|
+
const known = [...fields.keys()];
|
|
404
|
+
return {
|
|
405
|
+
status: 'rejected',
|
|
406
|
+
reason: known.length
|
|
407
|
+
? `${filter.field} cannot be filtered on. The fields that can are: ${known.join(', ')}.`
|
|
408
|
+
: `${filter.field} cannot be filtered on: this resource reports no filterable fields.`,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
const fragment = fragmentFor(filter, field);
|
|
412
|
+
if (fragment.status === 'rejected')
|
|
413
|
+
return fragment;
|
|
414
|
+
fragments.push(`(${fragment.criteria})`);
|
|
415
|
+
}
|
|
416
|
+
const criteria = fragments.length ? fragments.join(' && ') : undefined;
|
|
417
|
+
// The whole expression, not just each value: a hundred filters inside the per-value limit
|
|
418
|
+
// still compose something no read should send.
|
|
419
|
+
if (criteria && criteria.length > MAX_CRITERIA_LENGTH) {
|
|
420
|
+
return {
|
|
421
|
+
status: 'rejected',
|
|
422
|
+
reason: `those ${fragments.length} filters compose an expression of ${criteria.length} characters, and ${MAX_CRITERIA_LENGTH} is the most a read may send. Use fewer conditions.`,
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
return { status: 'ok', criteria };
|
|
426
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
import { logger } from '../utils/logger';
|
|
3
|
+
import { withTimeout } from '../utils/with-timeout';
|
|
4
|
+
/**
|
|
5
|
+
* A metadata request that never settles would pin the tool list for the page: Connect drops
|
|
6
|
+
* pending handlers on reconnect (`cleanMessages`) without rejecting them, so the await would
|
|
7
|
+
* hang forever and the assistant would silently stop listing tools. A timeout turns that into
|
|
8
|
+
* an ordinary transient failure, retried on the next turn.
|
|
9
|
+
*/
|
|
10
|
+
const METADATA_TIMEOUT_MS = 20000;
|
|
11
|
+
/**
|
|
12
|
+
* An enum's values. A live 8.15 request server sends them as an array
|
|
13
|
+
* (`VALID_VALUES: ["SELL", "BUY"]`), while grid-pro's enum filter reads a pipe-separated string,
|
|
14
|
+
* so both shapes are in use and both are accepted. Getting this wrong is silent: the model is
|
|
15
|
+
* simply never told what the field can hold.
|
|
16
|
+
*/
|
|
17
|
+
const validValuesOf = (detail) => {
|
|
18
|
+
const values = detail.VALID_VALUES;
|
|
19
|
+
if (Array.isArray(values)) {
|
|
20
|
+
const strings = values.filter((v) => typeof v === 'string' || typeof v === 'number');
|
|
21
|
+
return strings.length ? strings.map(String) : undefined;
|
|
22
|
+
}
|
|
23
|
+
return typeof values === 'string' && values.length ? values.split('|') : undefined;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* The metadata name of a resource: `REQ_TRADE` is read as `REQ_TRADE`, but its metadata and
|
|
27
|
+
* schema are published under `TRADE`. Asking for the prefixed name answers
|
|
28
|
+
* "Resource REQ_TRADE is not mapped to a service" (404), which would leave every tool
|
|
29
|
+
* unfilterable — verified against a live 8.15 router.
|
|
30
|
+
*
|
|
31
|
+
* @internal
|
|
32
|
+
*/
|
|
33
|
+
export const metadataNameOf = (resourceName) => resourceName.replace(/^REQ_/, '');
|
|
34
|
+
/** @internal */
|
|
35
|
+
export function filterFieldsFrom(metadata) {
|
|
36
|
+
var _a, _b, _c;
|
|
37
|
+
const details = new Map();
|
|
38
|
+
for (const detail of [...((_a = metadata.REPLY_FIELD) !== null && _a !== void 0 ? _a : []), ...((_b = metadata.FIELD) !== null && _b !== void 0 ? _b : [])]) {
|
|
39
|
+
if ((detail === null || detail === void 0 ? void 0 : detail.NAME) && !details.has(detail.NAME))
|
|
40
|
+
details.set(detail.NAME, detail);
|
|
41
|
+
}
|
|
42
|
+
// CRITERIA_FIELDS when the resource publishes one. A request server usually does not — it
|
|
43
|
+
// sends REPLY_FIELD only — and criteria there are evaluated against the row, so every reply
|
|
44
|
+
// field can be filtered on. (Live check: a criteria on a reply field that is not a request
|
|
45
|
+
// field filtered correctly, and an undeclared name came back as
|
|
46
|
+
// "Variable NOPE is not allowed in criteria expression".)
|
|
47
|
+
const criteriaFields = Array.isArray(metadata.CRITERIA_FIELDS) && metadata.CRITERIA_FIELDS.length
|
|
48
|
+
? metadata.CRITERIA_FIELDS
|
|
49
|
+
: [...details.keys()];
|
|
50
|
+
const fields = new Map();
|
|
51
|
+
const undescribed = [];
|
|
52
|
+
for (const name of criteriaFields) {
|
|
53
|
+
const detail = details.get(name);
|
|
54
|
+
// A criteria field the reply describes nowhere is LEFT OUT rather than guessed at. Calling
|
|
55
|
+
// it STRING was the guess, and it is wrong in both directions, silently: `AMOUNT == '100'`
|
|
56
|
+
// against a numeric column quotes a string at a number and matches nothing (this module's
|
|
57
|
+
// own header says the same about a timestamp), while `greater_than` on it is refused with a
|
|
58
|
+
// confident, wrong explanation — "AMOUNT is a string field" — that the model will act on by
|
|
59
|
+
// rewording the query for ever.
|
|
60
|
+
//
|
|
61
|
+
// The cost is real: a criteria-only request server publishes CRITERIA_FIELDS for every
|
|
62
|
+
// column of the table, so a field its reply does not carry is ordinary rather than exotic,
|
|
63
|
+
// and dropping it means the model reads more rows and narrows them itself. That is a slower
|
|
64
|
+
// right answer, and the alternative is a fast wrong one with nothing to notice it by.
|
|
65
|
+
if (!detail) {
|
|
66
|
+
undescribed.push(name);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
fields.set(name, { name, type: (_c = detail.TYPE) !== null && _c !== void 0 ? _c : 'STRING', validValues: validValuesOf(detail) });
|
|
70
|
+
}
|
|
71
|
+
return { fields, undescribed };
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Fetches a resource's filterable fields. Never throws.
|
|
75
|
+
*
|
|
76
|
+
* @remarks
|
|
77
|
+
* `transient` says whether asking again can help: a request that failed while the app was
|
|
78
|
+
* disconnected can, but a reply that came back unusable cannot — Connect caches any non-empty
|
|
79
|
+
* metadata reply for the session, so the same answer would come back without reaching the server.
|
|
80
|
+
*
|
|
81
|
+
* @internal
|
|
82
|
+
*/
|
|
83
|
+
export function loadFilterFields(connect_1, resourceName_1) {
|
|
84
|
+
return __awaiter(this, arguments, void 0, function* (connect, resourceName, timeoutMs = METADATA_TIMEOUT_MS) {
|
|
85
|
+
let metadata;
|
|
86
|
+
try {
|
|
87
|
+
metadata = yield withTimeout(connect.getMetadata(metadataNameOf(resourceName)), timeoutMs);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
// Classified by OUTCOME, not by the connection state before the await: a request that was
|
|
91
|
+
// sent while connected and failed mid-flight is exactly the case worth retrying.
|
|
92
|
+
return {
|
|
93
|
+
status: 'failed',
|
|
94
|
+
reason: `the metadata request failed (${String(error)})`,
|
|
95
|
+
transient: true,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (!metadata || typeof metadata !== 'object' || metadata.ERROR) {
|
|
99
|
+
// A reply came back and was unusable. Connect caches any non-empty metadata reply for the
|
|
100
|
+
// session, errors included, so asking again returns the same answer without reaching the
|
|
101
|
+
// server: settle rather than retry.
|
|
102
|
+
return {
|
|
103
|
+
status: 'failed',
|
|
104
|
+
reason: (metadata === null || metadata === void 0 ? void 0 : metadata.ERROR)
|
|
105
|
+
? `the app refused its metadata (${metadata.ERROR})`
|
|
106
|
+
: 'the app returned no usable metadata for it',
|
|
107
|
+
transient: !metadata,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const { fields, undescribed } = filterFieldsFrom(metadata);
|
|
111
|
+
if (undescribed.length) {
|
|
112
|
+
logger.warn(`Genesis assistant: ${resourceName} lists ${undescribed.join(', ')} as criteria fields but ` +
|
|
113
|
+
'describes neither type, so they are not offered as filters. Publish them in the ' +
|
|
114
|
+
"resource's reply fields to make them filterable.");
|
|
115
|
+
}
|
|
116
|
+
// Whether the reply DESCRIBED any fields at all, so the caller can tell "this resource has
|
|
117
|
+
// nothing to filter on" from "we asked in the wrong place, or the platform sent nothing".
|
|
118
|
+
// Presence of the keys, not their length: a resource that answers with empty lists HAS
|
|
119
|
+
// described itself — it has nothing to filter on — while a reply that mentions none of them
|
|
120
|
+
// has described nothing, which is a different thing to tell the host.
|
|
121
|
+
const published = ['CRITERIA_FIELDS', 'REPLY_FIELD', 'FIELD'].some((key) => key in metadata);
|
|
122
|
+
return { status: 'ok', fields, published };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Genesis bridge for the AI assistant (`@genesislcap/ai-assistant/genesis`).
|
|
3
|
+
*
|
|
4
|
+
* Its own entry point, deliberately not re-exported from the package root: it depends on
|
|
5
|
+
* `@genesislcap/foundation-comms`, and this package declares no `sideEffects`, so anything
|
|
6
|
+
* exported from the root lands in every consumer's bundle — including hosts that are not
|
|
7
|
+
* Genesis apps. foundation-comms is an optional peer dependency, so the bridge always resolves
|
|
8
|
+
* the app's own `Connect` rather than a second copy with a different DI token.
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
* @beta
|
|
12
|
+
*/
|
|
13
|
+
export { GENESIS_AGENT_NAME, registerGenesisAssistant } from './register-genesis-assistant';
|
|
14
|
+
export { createGenesisResourceTools, GENESIS_DATA_SOURCE } from './resource-tools';
|
|
15
|
+
export { GENESIS_EVENT_OPS } from './types';
|