@duckcodeailabs/dql-agent 1.14.3-rc.1 → 1.14.4
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/agent-run-engine.d.ts +177 -3
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +2061 -57
- package/dist/agent-run-engine.js.map +1 -1
- package/dist/agent-run-gates.d.ts.map +1 -1
- package/dist/agent-run-gates.js +51 -3
- package/dist/agent-run-gates.js.map +1 -1
- package/dist/agent-run-store.d.ts.map +1 -1
- package/dist/agent-run-store.js +544 -9
- package/dist/agent-run-store.js.map +1 -1
- package/dist/agentic/analyst-loop.d.ts +2 -0
- package/dist/agentic/analyst-loop.d.ts.map +1 -1
- package/dist/agentic/analyst-loop.js +41 -4
- package/dist/agentic/analyst-loop.js.map +1 -1
- package/dist/agentic/ledger-tools.d.ts.map +1 -1
- package/dist/agentic/ledger-tools.js +8 -1
- package/dist/agentic/ledger-tools.js.map +1 -1
- package/dist/agentic/orchestrator-policy.d.ts +3 -1
- package/dist/agentic/orchestrator-policy.d.ts.map +1 -1
- package/dist/agentic/orchestrator-policy.js +1 -1
- package/dist/agentic/orchestrator-policy.js.map +1 -1
- package/dist/agentic/tool-loop.d.ts +13 -1
- package/dist/agentic/tool-loop.d.ts.map +1 -1
- package/dist/agentic/tool-loop.js +441 -32
- package/dist/agentic/tool-loop.js.map +1 -1
- package/dist/analytical-frame.d.ts +19 -0
- package/dist/analytical-frame.d.ts.map +1 -1
- package/dist/analytical-frame.js +170 -12
- package/dist/analytical-frame.js.map +1 -1
- package/dist/analytical-orchestration.d.ts +949 -10
- package/dist/analytical-orchestration.d.ts.map +1 -1
- package/dist/analytical-orchestration.js +499 -56
- package/dist/analytical-orchestration.js.map +1 -1
- package/dist/answer-loop.d.ts +166 -2
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +654 -18
- package/dist/answer-loop.js.map +1 -1
- package/dist/answer-shape.d.ts +27 -0
- package/dist/answer-shape.d.ts.map +1 -1
- package/dist/answer-shape.js +55 -0
- package/dist/answer-shape.js.map +1 -1
- package/dist/ask-observability/instrumentation.d.ts.map +1 -1
- package/dist/ask-observability/instrumentation.js +52 -1
- package/dist/ask-observability/instrumentation.js.map +1 -1
- package/dist/ask-observability/portable.js +562 -0
- package/dist/ask-observability/portable.js.map +1 -1
- package/dist/ask-observability/types.d.ts +26 -3
- package/dist/ask-observability/types.d.ts.map +1 -1
- package/dist/ask-observability/types.js.map +1 -1
- package/dist/ask-runtime/ask-agent-runtime-v2.d.ts +850 -0
- package/dist/ask-runtime/ask-agent-runtime-v2.d.ts.map +1 -0
- package/dist/ask-runtime/ask-agent-runtime-v2.js +1716 -0
- package/dist/ask-runtime/ask-agent-runtime-v2.js.map +1 -0
- package/dist/ask-runtime/ask-analyst-runtime.d.ts +95 -0
- package/dist/ask-runtime/ask-analyst-runtime.d.ts.map +1 -0
- package/dist/ask-runtime/ask-analyst-runtime.js +8280 -0
- package/dist/ask-runtime/ask-analyst-runtime.js.map +1 -0
- package/dist/conversation/session-store.d.ts +31 -0
- package/dist/conversation/session-store.d.ts.map +1 -1
- package/dist/conversation/session-store.js +38 -0
- package/dist/conversation/session-store.js.map +1 -1
- package/dist/conversation/snapshot.d.ts +4 -1
- package/dist/conversation/snapshot.d.ts.map +1 -1
- package/dist/conversation/snapshot.js +85 -3
- package/dist/conversation/snapshot.js.map +1 -1
- package/dist/conversation/turn-trust.d.ts.map +1 -1
- package/dist/conversation/turn-trust.js +2 -0
- package/dist/conversation/turn-trust.js.map +1 -1
- package/dist/embeddings/provider.d.ts +1 -0
- package/dist/embeddings/provider.d.ts.map +1 -1
- package/dist/embeddings/provider.js +18 -0
- package/dist/embeddings/provider.js.map +1 -1
- package/dist/generated-analytical-proposal.js +37 -2
- package/dist/generated-analytical-proposal.js.map +1 -1
- package/dist/index.d.ts +15 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +77 -1
- package/dist/intent-controller.d.ts.map +1 -1
- package/dist/intent-controller.js +1 -1
- package/dist/intent-controller.js.map +1 -1
- package/dist/kg/build.js +32 -2
- package/dist/kg/build.js.map +1 -1
- package/dist/meaning-resolution.d.ts +129 -0
- package/dist/meaning-resolution.d.ts.map +1 -1
- package/dist/meaning-resolution.js +108 -15
- package/dist/meaning-resolution.js.map +1 -1
- package/dist/metadata/analysis-planner.js +57 -6
- package/dist/metadata/analysis-planner.js.map +1 -1
- package/dist/metadata/catalog.d.ts +14 -0
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +28 -1
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/meaning-evidence.d.ts +19 -0
- package/dist/metadata/meaning-evidence.d.ts.map +1 -1
- package/dist/metadata/meaning-evidence.js +113 -6
- package/dist/metadata/meaning-evidence.js.map +1 -1
- package/dist/provider-egress.d.ts +4 -1
- package/dist/provider-egress.d.ts.map +1 -1
- package/dist/provider-egress.js +50 -5
- package/dist/provider-egress.js.map +1 -1
- package/dist/providers/claude.d.ts +2 -2
- package/dist/providers/claude.d.ts.map +1 -1
- package/dist/providers/claude.js +191 -28
- package/dist/providers/claude.js.map +1 -1
- package/dist/providers/index.d.ts +1 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/openai.d.ts +2 -2
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +191 -21
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/types.d.ts +79 -1
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/requirement-clauses.d.ts +23 -0
- package/dist/requirement-clauses.d.ts.map +1 -0
- package/dist/requirement-clauses.js +70 -0
- package/dist/requirement-clauses.js.map +1 -0
- package/dist/resolved-analytical-plan.d.ts.map +1 -1
- package/dist/resolved-analytical-plan.js +201 -15
- package/dist/resolved-analytical-plan.js.map +1 -1
- package/dist/router.d.ts +109 -2
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +753 -76
- package/dist/router.js.map +1 -1
- package/package.json +4 -4
|
@@ -12,7 +12,57 @@
|
|
|
12
12
|
* Acceptance: AGT-027..033, CTX-007, PERF-003, E2E-022.
|
|
13
13
|
*/
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
|
+
import { parseAnalyticalTimeWindow } from './requirement-clauses.js';
|
|
15
16
|
export const ANALYTICAL_ORCHESTRATION_CONTRACT_VERSION = 1;
|
|
17
|
+
/**
|
|
18
|
+
* Preserve a validated prior-result predicate as typed analytical context.
|
|
19
|
+
* The selected display key supplies the entity/display requirements needed to
|
|
20
|
+
* retrieve relationship closure; the literal values remain an immutable host
|
|
21
|
+
* filter and are never treated as a provider-selected member meaning.
|
|
22
|
+
*/
|
|
23
|
+
export function withAnalyticalPriorResultMemberBinding(requirements, binding) {
|
|
24
|
+
const displayDimension = binding?.displayDimension?.trim();
|
|
25
|
+
const seenValues = new Set();
|
|
26
|
+
const values = (binding?.values ?? []).flatMap((value) => {
|
|
27
|
+
const trimmed = value.trim();
|
|
28
|
+
const normalized = normalizeRequirementTerm(trimmed);
|
|
29
|
+
if (!trimmed || !normalized || seenValues.has(normalized))
|
|
30
|
+
return [];
|
|
31
|
+
seenValues.add(normalized);
|
|
32
|
+
return [trimmed];
|
|
33
|
+
});
|
|
34
|
+
if (!displayDimension || values.length === 0)
|
|
35
|
+
return requirements;
|
|
36
|
+
const normalizedDisplay = normalizeRequirementTerm(displayDimension);
|
|
37
|
+
const entityTerm = normalizedDisplay
|
|
38
|
+
.replace(/\b(?:name|id|key|label|email)\b/g, ' ')
|
|
39
|
+
.replace(/\s+/g, ' ')
|
|
40
|
+
.trim();
|
|
41
|
+
return {
|
|
42
|
+
...requirements,
|
|
43
|
+
entityTerms: uniqueRequirementTerms([
|
|
44
|
+
...requirements.entityTerms,
|
|
45
|
+
...(entityTerm ? [entityTerm] : []),
|
|
46
|
+
]),
|
|
47
|
+
entityDisplayTerms: uniqueRequirementTerms([
|
|
48
|
+
...requirements.entityDisplayTerms,
|
|
49
|
+
normalizedDisplay,
|
|
50
|
+
]),
|
|
51
|
+
// These values are retained for local compiler/filter construction. The
|
|
52
|
+
// planner request redacts this host-only binding and does not require a
|
|
53
|
+
// member card when it is present.
|
|
54
|
+
memberTerms: uniqueRequirementTerms([...requirements.memberTerms, ...values]),
|
|
55
|
+
priorResultMemberBinding: {
|
|
56
|
+
version: 1,
|
|
57
|
+
// Preserve the canonical host field spelling for the immutable filter;
|
|
58
|
+
// normalized text above is only for role matching/admission.
|
|
59
|
+
displayDimension,
|
|
60
|
+
values,
|
|
61
|
+
...(binding?.sourceTurnId ? { sourceTurnId: binding.sourceTurnId } : {}),
|
|
62
|
+
...(binding?.resultFingerprint ? { resultFingerprint: binding.resultFingerprint } : {}),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
16
66
|
/** Build the immutable host request tuple before a meaning model can respond. */
|
|
17
67
|
export function buildAnalyticalRequirementSeedV1(input) {
|
|
18
68
|
// Retrieval/parser output is intentionally broad: it may contain useful
|
|
@@ -22,10 +72,11 @@ export function buildAnalyticalRequirementSeedV1(input) {
|
|
|
22
72
|
// frozen host tuple. Structured clarification selections are merged by the
|
|
23
73
|
// router into `requirements` before this function is called.
|
|
24
74
|
const parsed = currentQuestionGroundedParsedIntent(input.question, input.parsedIntent);
|
|
25
|
-
const
|
|
75
|
+
const baseRequirements = input.requirements ?? buildAnalyticalRequirementSet({
|
|
26
76
|
question: input.question,
|
|
27
77
|
parsedIntent: parsed,
|
|
28
78
|
});
|
|
79
|
+
const requirements = withAnalyticalPriorResultMemberBinding(baseRequirements, input.priorResultMemberBinding ?? baseRequirements.priorResultMemberBinding);
|
|
29
80
|
// Order and limit are lexical requirements, not parser defaults. In
|
|
30
81
|
// particular, a prior ranking must not turn a complete new question into a
|
|
31
82
|
// top-N query just because retrieval retained an old `limit` or `order`.
|
|
@@ -34,6 +85,13 @@ export function buildAnalyticalRequirementSeedV1(input) {
|
|
|
34
85
|
: undefined;
|
|
35
86
|
const limit = requirements.ranking?.limit;
|
|
36
87
|
const filters = [...(parsed?.filters ?? [])].map((filter) => ({ field: filter.field, value: filter.value }));
|
|
88
|
+
for (const value of requirements.priorResultMemberBinding?.values ?? []) {
|
|
89
|
+
const field = requirements.priorResultMemberBinding?.displayDimension;
|
|
90
|
+
if (!field || filters.some((filter) => normalizeRequirementTerm(filter.field) === normalizeRequirementTerm(field)
|
|
91
|
+
&& normalizeRequirementTerm(filter.value) === normalizeRequirementTerm(value)))
|
|
92
|
+
continue;
|
|
93
|
+
filters.push({ field, value });
|
|
94
|
+
}
|
|
37
95
|
if (requirements.time?.fiscalPeriod && input.fiscalCalendar?.fiscalPeriodFieldId
|
|
38
96
|
&& !filters.some((filter) => filter.field === input.fiscalCalendar.fiscalPeriodFieldId)) {
|
|
39
97
|
filters.push({ field: input.fiscalCalendar.fiscalPeriodFieldId, value: requirements.time.fiscalPeriod });
|
|
@@ -56,7 +114,13 @@ export function buildAnalyticalRequirementSeedV1(input) {
|
|
|
56
114
|
...requirements.entityDisplayTerms,
|
|
57
115
|
])],
|
|
58
116
|
filters,
|
|
59
|
-
|
|
117
|
+
// The typed window is the host's own reading of the question and wins
|
|
118
|
+
// over a retrieval refinement. Its canonical expression is exactly the
|
|
119
|
+
// input `resolvePlanTimeRange` accepts, so populating it is what makes
|
|
120
|
+
// that (previously never-called) resolver finally produce timeBounds.
|
|
121
|
+
...(requirements.time?.window
|
|
122
|
+
? { timeRange: requirements.time.window.expression }
|
|
123
|
+
: parsed?.timeRange ? { timeRange: parsed.timeRange } : {}),
|
|
60
124
|
...(requirements.time?.grain
|
|
61
125
|
? { timeGrain: requirements.time.grain }
|
|
62
126
|
: parsed?.timeGrain ? { timeGrain: parsed.timeGrain } : {}),
|
|
@@ -118,8 +182,18 @@ export function currentQuestionGroundedParsedIntent(question, parsedIntent) {
|
|
|
118
182
|
return false;
|
|
119
183
|
if (groundedTerm(normalized))
|
|
120
184
|
return true;
|
|
121
|
-
|
|
122
|
-
|
|
185
|
+
if (!/^(?:last|previous|past) (?:\d+|one|two|three|four|five|six|seven|eight|nine|ten|twelve) (?:day|days|week|weeks|month|months|quarter|quarters|year|years)$/.test(normalized))
|
|
186
|
+
return false;
|
|
187
|
+
// The question may spell the count ("last two months") while the parsed
|
|
188
|
+
// range uses digits ("last 2 months"). Both spell the same window; ground
|
|
189
|
+
// on the digit-normalized forms so the wording difference cannot unground
|
|
190
|
+
// a range the question itself established.
|
|
191
|
+
const digits = (value2) => value2
|
|
192
|
+
.replace(/\bone\b/g, '1').replace(/\btwo\b/g, '2').replace(/\bthree\b/g, '3')
|
|
193
|
+
.replace(/\bfour\b/g, '4').replace(/\bfive\b/g, '5').replace(/\bsix\b/g, '6')
|
|
194
|
+
.replace(/\bseven\b/g, '7').replace(/\beight\b/g, '8').replace(/\bnine\b/g, '9')
|
|
195
|
+
.replace(/\btwelve\b/g, '12').replace(/\bten\b/g, '10');
|
|
196
|
+
return digits(normalizeRequirementTerm(question)).includes(digits(normalized));
|
|
123
197
|
};
|
|
124
198
|
const measures = (parsedIntent.measures ?? []).filter(groundedTerm);
|
|
125
199
|
const dimensions = (parsedIntent.dimensions ?? []).filter(groundedTerm);
|
|
@@ -140,6 +214,63 @@ export function currentQuestionGroundedParsedIntent(question, parsedIntent) {
|
|
|
140
214
|
// question below. Never promote parser values by themselves.
|
|
141
215
|
};
|
|
142
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Preserve a small set of explicit current-question literals even when a
|
|
219
|
+
* retriever did not emit a parser filter. This is deliberately not a value
|
|
220
|
+
* search or synonym engine: it records only quoted values, capitalized proper
|
|
221
|
+
* names, and values introduced by an explicit predicate phrase. The planner
|
|
222
|
+
* must still bind each term to an admitted, qualified member/dimension card
|
|
223
|
+
* before it can become an executable filter.
|
|
224
|
+
*
|
|
225
|
+
* Without this host-owned atom, "customers in Philadelphia" could reach a
|
|
226
|
+
* broad certified fit after retrieval silently omitted the parser filter.
|
|
227
|
+
*/
|
|
228
|
+
export function currentQuestionLiteralMemberTerms(question) {
|
|
229
|
+
const literals = [];
|
|
230
|
+
const append = (value, source = 'predicate') => {
|
|
231
|
+
const trimmed = (value ?? '').trim()
|
|
232
|
+
.replace(/^(?:the\s+)/i, '')
|
|
233
|
+
.replace(/[?.!,;:]+$/g, '')
|
|
234
|
+
.trim();
|
|
235
|
+
const normalized = normalizeRequirementTerm(trimmed);
|
|
236
|
+
if (!normalized || isTemporalTerm(normalized))
|
|
237
|
+
return;
|
|
238
|
+
// Do not mistake grammatical/analytical words for a member literal.
|
|
239
|
+
if (/^(?:by|with|and|or|for|where|that|which|who|having|have)\b/i.test(normalized))
|
|
240
|
+
return;
|
|
241
|
+
if (/^(?:show|who|what|which|where|when|why|how|top|bottom|highest|lowest|revenue|sales|customers?|accounts?|products?|orders?|regions?|categories?|category|region|customer|account|product|order)$/i.test(normalized))
|
|
242
|
+
return;
|
|
243
|
+
// A two-word title-cased fragment is not automatically a proper name.
|
|
244
|
+
// Sentence-leading analytical phrases such as `Show Revenue` and `Top
|
|
245
|
+
// Customers` otherwise become fake member atoms, then falsely demand a
|
|
246
|
+
// member field before the compiler can reach a safe physical fallback.
|
|
247
|
+
// Quoted text remains an explicit reader literal; only heuristic proper
|
|
248
|
+
// name extraction applies this conservative vocabulary guard.
|
|
249
|
+
if (source === 'proper_name') {
|
|
250
|
+
const words = normalized.split(/\s+/);
|
|
251
|
+
if (words.some((word) => /^(?:show|list|give|find|get|top|bottom|highest|lowest|revenue|sales|customer|customers|account|accounts|product|products|order|orders|region|regions|category|categories|metric|metrics|amount|count|total|average|avg|monthly|daily|yearly)$/i.test(word)))
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
literals.push(trimmed);
|
|
255
|
+
};
|
|
256
|
+
// A reader can make a value unambiguous with quotes regardless of casing.
|
|
257
|
+
for (const match of question.matchAll(/["“]([^"”]{2,96})["”]/g))
|
|
258
|
+
append(match[1], 'quoted');
|
|
259
|
+
// Keep a literal only when the question itself supplies a predicate-like
|
|
260
|
+
// construction. The bounded lookahead avoids swallowing "by revenue" or a
|
|
261
|
+
// second clause into the value.
|
|
262
|
+
const predicate = /\b(?:in|from|at|named|called)\s+(?:the\s+)?([A-Za-z][A-Za-z0-9'/-]*(?:\s+[A-Za-z][A-Za-z0-9'/-]*){0,3})(?=\s*(?:\b(?:by|with|and|or|for|where|that|which|who|having|have)\b|[?.!,;]|$))/gi;
|
|
263
|
+
for (const match of question.matchAll(predicate))
|
|
264
|
+
append(match[1], 'predicate');
|
|
265
|
+
// Proper names such as "Brittany Barrera" are a member requirement even
|
|
266
|
+
// when the reader phrases an attribute lookup rather than a SQL-style
|
|
267
|
+
// predicate. Single capitalized words are intentionally handled only by the
|
|
268
|
+
// predicate branch above so sentence-leading generic words do not become
|
|
269
|
+
// fake filters.
|
|
270
|
+
for (const match of question.matchAll(/\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,3})\b/g))
|
|
271
|
+
append(match[1], 'proper_name');
|
|
272
|
+
return uniqueRequirementTerms(literals);
|
|
273
|
+
}
|
|
143
274
|
/**
|
|
144
275
|
* Small, explainable intent vocabulary used before the physical plan exists.
|
|
145
276
|
* It is deliberately not a second semantic matcher: candidate identity still
|
|
@@ -319,14 +450,15 @@ function explicitOutputTerms(question) {
|
|
|
319
450
|
* or "for each customer" as separate physical measures. The grouping entity
|
|
320
451
|
* is represented by the dimension/entity roles instead.
|
|
321
452
|
*
|
|
322
|
-
* `order count for each customer` is the common prose form for
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
* metric
|
|
453
|
+
* `order count for each customer` is the common prose form for the authored
|
|
454
|
+
* `order_count` semantic measure at customer grain. Keep that compound
|
|
455
|
+
* identity intact: reducing it to the generic aggregation `count` makes the
|
|
456
|
+
* unfiltered `Orders` MetricFlow metric indistinguishable from scoped metrics
|
|
457
|
+
* such as `Drink Orders` and `Food Orders`. The grouping entity is still
|
|
458
|
+
* represented by the dimension/entity roles rather than becoming another
|
|
459
|
+
* measure.
|
|
326
460
|
*/
|
|
327
461
|
export function normalizeAnalyticalMeasureTerms(question, values, options = {}) {
|
|
328
|
-
const normalizedQuestion = normalizeRequirementTerm(question);
|
|
329
|
-
const groupedOrderCount = /\borders?\s+count\s+(?:for|per)\s+(?:each|every)\s+(?:the\s+)?(?:account|customer|client|company|product|order|item|row)s?\b/.test(normalizedQuestion);
|
|
330
462
|
// A parser often singularizes the business alias `sales` into `sale`. That
|
|
331
463
|
// is not a second measure beside revenue: it is the same current-question
|
|
332
464
|
// request. Canonicalize only that standalone vocabulary alias here; named
|
|
@@ -337,11 +469,7 @@ export function normalizeAnalyticalMeasureTerms(question, values, options = {})
|
|
|
337
469
|
};
|
|
338
470
|
const terms = values
|
|
339
471
|
.map(canonicalMeasureAlias)
|
|
340
|
-
.filter((value) => !isStructuralMeasurePhrase(value))
|
|
341
|
-
.filter((value) => !(groupedOrderCount && /^(?:order|orders)$/i.test(normalizeRequirementTerm(value))));
|
|
342
|
-
if (groupedOrderCount && !terms.some((value) => normalizeRequirementTerm(value) === 'count')) {
|
|
343
|
-
terms.push('count');
|
|
344
|
-
}
|
|
472
|
+
.filter((value) => !isStructuralMeasurePhrase(value));
|
|
345
473
|
// An inherited measure can already be a stable semantic/dbt identity. Keep
|
|
346
474
|
// that identity intact for the planner/meaning handoff; matching and display
|
|
347
475
|
// have their own normalizers. Rewriting `total_consumption_units` to prose
|
|
@@ -365,20 +493,72 @@ export function normalizeAnalyticalMeasureTerms(question, values, options = {})
|
|
|
365
493
|
* phrase that already owns it: adding both `beverage revenue` and `revenue`
|
|
366
494
|
* turns one requested metric into two and incorrectly rejects a block whose
|
|
367
495
|
* own declared output is `beverage_revenue`. The same holds for `order count`
|
|
368
|
-
* and its generic `count` root;
|
|
369
|
-
*
|
|
496
|
+
* and its generic `count` root; the authored compound is retained and owns
|
|
497
|
+
* that generic aggregation vocabulary for this question.
|
|
370
498
|
*/
|
|
371
499
|
function nonRedundantLexicalMeasureTerms(parsedMeasures, question) {
|
|
372
|
-
const
|
|
500
|
+
const compoundTerms = explicitQuestionCompoundMeasureTerms(question);
|
|
501
|
+
const lexical = [
|
|
502
|
+
...compoundTerms,
|
|
503
|
+
...['revenue', 'refund', 'refunds', 'bcm', 'run rate', 'count'],
|
|
504
|
+
]
|
|
505
|
+
// `BCM run rate` is one named business measure, not the independent
|
|
506
|
+
// lexical roots `bcm` and `run rate`.
|
|
507
|
+
.filter((term) => !compoundTerms.some((compound) => compound !== term && compound.includes(term)))
|
|
373
508
|
.filter((term) => new RegExp(`\\b${term.replace(' ', '\\s+')}\\b`, 'i').test(question));
|
|
374
509
|
return lexical.filter((term) => {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
510
|
+
// A multi-word lexical root such as `run rate` must be considered owned
|
|
511
|
+
// by `BCM run rate`. The old word-by-word comparison only worked for a
|
|
512
|
+
// one-word root and added both phrases as independent measures, which
|
|
513
|
+
// prevented an otherwise exact semantic metric from binding without a
|
|
514
|
+
// provider. Treat the lexical phrase as redundant when all of its tokens
|
|
515
|
+
// are already present in one parsed measure. Keep the narrow refund
|
|
516
|
+
// singular/plural equivalence so existing vocabulary remains stable.
|
|
517
|
+
const lexicalTokens = normalizeRequirementTerm(term)
|
|
380
518
|
.split(' ')
|
|
381
|
-
.
|
|
519
|
+
.filter(Boolean)
|
|
520
|
+
.map((token) => token === 'refunds' ? 'refund' : token);
|
|
521
|
+
return !parsedMeasures.some((measure) => {
|
|
522
|
+
const measureTokens = new Set(normalizeRequirementTerm(measure)
|
|
523
|
+
.split(' ')
|
|
524
|
+
.filter(Boolean)
|
|
525
|
+
.map((token) => token === 'refunds' ? 'refund' : token));
|
|
526
|
+
return lexicalTokens.every((token) => measureTokens.has(token));
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Preserve explicit multi-word business measures when a retrieval parser
|
|
532
|
+
* emits only a suffix such as `rate`. This is intentionally a compact,
|
|
533
|
+
* vocabulary-backed list rather than a speculative phrase synthesizer: it
|
|
534
|
+
* upgrades only a phrase the user actually wrote and whose complete meaning
|
|
535
|
+
* is common in the governed analytics catalog.
|
|
536
|
+
*/
|
|
537
|
+
function explicitQuestionCompoundMeasureTerms(question) {
|
|
538
|
+
return [
|
|
539
|
+
...(/\borders?\s+count\b/i.test(question) ? ['order count'] : []),
|
|
540
|
+
...(/\bbcm\s+run\s+rate\b/i.test(question) ? ['bcm run rate'] : []),
|
|
541
|
+
];
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* A parser fragment must not become a second measure when the source question
|
|
545
|
+
* contains an explicit compound business measure that subsumes it. For
|
|
546
|
+
* example, `rate` plus `BCM run rate` is one requested metric. We only remove
|
|
547
|
+
* strict token subsets of a phrase explicitly present in the user question;
|
|
548
|
+
* independent named measures remain separate requirements.
|
|
549
|
+
*/
|
|
550
|
+
function preferExplicitQuestionCompoundMeasures(terms, question) {
|
|
551
|
+
const compounds = explicitQuestionCompoundMeasureTerms(question)
|
|
552
|
+
.map((term) => ({ term, tokens: normalizeRequirementTerm(term).split(' ').filter(Boolean) }));
|
|
553
|
+
if (compounds.length === 0)
|
|
554
|
+
return uniqueRequirementTerms([...terms]);
|
|
555
|
+
return uniqueRequirementTerms([...terms]).filter((term) => {
|
|
556
|
+
const normalized = normalizeRequirementTerm(term);
|
|
557
|
+
const tokens = normalized.split(' ').filter(Boolean);
|
|
558
|
+
return !compounds.some((compound) => normalized !== compound.term
|
|
559
|
+
&& tokens.length > 0
|
|
560
|
+
&& tokens.length < compound.tokens.length
|
|
561
|
+
&& tokens.every((token) => compound.tokens.includes(token)));
|
|
382
562
|
});
|
|
383
563
|
}
|
|
384
564
|
/**
|
|
@@ -466,7 +646,7 @@ export function buildAnalyticalRequirementSet(input) {
|
|
|
466
646
|
const salesIsRevenueAlias = /\bsales\b/i.test(question);
|
|
467
647
|
const parsedMeasures = normalizeAnalyticalMeasureTerms(question, parsed?.measures ?? [])
|
|
468
648
|
.filter((measure) => !/\bsales\s+based\s+on\b/.test(normalizeRequirementTerm(measure)));
|
|
469
|
-
const parsedMeasuresWithLexicalTerms = uniqueRequirementTerms([
|
|
649
|
+
const parsedMeasuresWithLexicalTerms = preferExplicitQuestionCompoundMeasures(uniqueRequirementTerms([
|
|
470
650
|
...parsedMeasures,
|
|
471
651
|
...nonRedundantLexicalMeasureTerms(parsedMeasures, question),
|
|
472
652
|
...(salesIsRevenueAlias ? ['revenue'] : []),
|
|
@@ -478,7 +658,7 @@ export function buildAnalyticalRequirementSet(input) {
|
|
|
478
658
|
&& !parsedMeasures.some((measure) => normalizeRequirementTerm(measure).split(' ').includes('amount'))
|
|
479
659
|
? ['amount']
|
|
480
660
|
: []),
|
|
481
|
-
]);
|
|
661
|
+
]), question);
|
|
482
662
|
const typedRequirements = normalizedTypedAggregationRequirements({
|
|
483
663
|
question,
|
|
484
664
|
measures: parsedMeasuresWithLexicalTerms,
|
|
@@ -493,7 +673,13 @@ export function buildAnalyticalRequirementSet(input) {
|
|
|
493
673
|
const measures = explicitRankingMeasures.length > 0
|
|
494
674
|
? explicitRankingMeasures
|
|
495
675
|
: typedRequirements.measures;
|
|
496
|
-
|
|
676
|
+
// Retrieval/parser hints sometimes repeat an explicit measure as a
|
|
677
|
+
// dimension (for example `revenue` in "show revenue by region"). Keep
|
|
678
|
+
// the user-authored measure authoritative and remove only an exact
|
|
679
|
+
// normalized duplicate. A broader substring rule would incorrectly drop
|
|
680
|
+
// legitimate dimensions such as `product revenue category`.
|
|
681
|
+
const measureTerms = new Set(measures.map((measure) => normalizeRequirementTerm(measure)));
|
|
682
|
+
const dimensions = typedRequirements.dimensions.filter((dimension) => !measureTerms.has(normalizeRequirementTerm(dimension)));
|
|
497
683
|
const rankingMetricTerms = ranking ? measures : [];
|
|
498
684
|
// A context planner supplies its own safety default (`topN: 10`) for bare
|
|
499
685
|
// rankings. It is a useful execution bound, but it is not user intent. Read
|
|
@@ -509,10 +695,15 @@ export function buildAnalyticalRequirementSet(input) {
|
|
|
509
695
|
? Number(ranking[2])
|
|
510
696
|
: leadingOrdinalRanking?.[1] ? Number(leadingOrdinalRanking[1])
|
|
511
697
|
: wordRankingLimit ? explicitWordLimit[wordRankingLimit] : undefined;
|
|
512
|
-
|
|
698
|
+
// A bounded window is a restriction on WHEN, distinct from grouping grain.
|
|
699
|
+
// It gets its own typed clause; without one, "last two months" had nowhere
|
|
700
|
+
// to live and silently vanished between the question and the query.
|
|
701
|
+
const window = parseAnalyticalTimeWindow(question);
|
|
702
|
+
const time = grain || fiscalPeriod || window
|
|
513
703
|
? {
|
|
514
704
|
role: grain ? 'time_axis' : 'time_filter',
|
|
515
705
|
...(grain ? { grain: grain } : {}),
|
|
706
|
+
...(window ? { window } : {}),
|
|
516
707
|
...(fiscalPeriod ? { fiscalPeriod } : {}),
|
|
517
708
|
requiresDeclaredFiscalCalendar: Boolean(fiscalPeriod),
|
|
518
709
|
}
|
|
@@ -523,7 +714,14 @@ export function buildAnalyticalRequirementSet(input) {
|
|
|
523
714
|
dimensions,
|
|
524
715
|
entityTerms,
|
|
525
716
|
entityDisplayTerms,
|
|
526
|
-
|
|
717
|
+
// A parser filter is useful but not the sole authority for an explicit
|
|
718
|
+
// current-turn member. Preserve bounded literal atoms too; downstream
|
|
719
|
+
// planning must still bind them to qualified snapshot evidence before a
|
|
720
|
+
// field/value predicate can be frozen.
|
|
721
|
+
memberTerms: uniqueRequirementTerms([
|
|
722
|
+
...(parsed?.filters ?? []).map((filter) => filter.value),
|
|
723
|
+
...currentQuestionLiteralMemberTerms(question),
|
|
724
|
+
]),
|
|
527
725
|
...(explicitOutputTerms(question).length > 0 ? { outputTerms: explicitOutputTerms(question) } : {}),
|
|
528
726
|
...(/\bindividual\b/i.test(question) ? { grain: 'individual' } : {}),
|
|
529
727
|
...(ranking
|
|
@@ -590,16 +788,61 @@ export function hasEntityAttributeTerm(value) {
|
|
|
590
788
|
export function isEntityAttributeCandidate(candidate) {
|
|
591
789
|
return hasEntityAttributeTerm(intrinsicCandidateIdentity(candidate));
|
|
592
790
|
}
|
|
791
|
+
/**
|
|
792
|
+
* Time is a type-level role, not a lexical synonym. Semantic indexes from
|
|
793
|
+
* dbt/MetricFlow commonly retain all members as `dimension`, so a typed
|
|
794
|
+
* `opened_date` must not also enter the ordinary categorical/geographic
|
|
795
|
+
* fallback lane. A card-level semantic-time class or physical/semantic type
|
|
796
|
+
* is authoritative. `timeGrains` alone is deliberately not: older retrieval
|
|
797
|
+
* adapters can inherit a model/metric's supported grains onto unrelated
|
|
798
|
+
* entity and display cards. Names are used only for legacy cards with no
|
|
799
|
+
* source type.
|
|
800
|
+
*/
|
|
801
|
+
function candidateHasDeclaredTimeRole(candidate) {
|
|
802
|
+
if (candidate.semanticObjectType === 'time_dimension')
|
|
803
|
+
return true;
|
|
804
|
+
const dataType = normalizeRequirementTerm(candidate.dataType ?? '');
|
|
805
|
+
return /(?:^| )(?:date|datetime|timestamp|timestamptz|timestampntz|time)(?:$| )/.test(dataType);
|
|
806
|
+
}
|
|
807
|
+
function candidateUsesLegacyTimeNameFallback(candidate) {
|
|
808
|
+
// A supplied type is authoritative even when a legacy name happens to
|
|
809
|
+
// contain `date` (for example a text display label). A missing/empty
|
|
810
|
+
// time-grain list is not a positive type declaration, so old untyped cards
|
|
811
|
+
// retain this safe fallback.
|
|
812
|
+
if (normalizeRequirementTerm(candidate.dataType ?? ''))
|
|
813
|
+
return false;
|
|
814
|
+
return /(?:\bdate\b|\btime\b|\bmonth\b|\bquarter\b|\byear\b|\bfiscal\b|\bperiod\b)/
|
|
815
|
+
.test(intrinsicCandidateIdentity(candidate));
|
|
816
|
+
}
|
|
593
817
|
/** Classify the role an already-qualified candidate may fill. */
|
|
594
818
|
export function evidenceCandidateRoles(candidate) {
|
|
595
819
|
const identity = intrinsicCandidateIdentity(candidate);
|
|
596
820
|
const roles = new Set();
|
|
597
821
|
const physicalColumn = candidate.kind === 'sql_column';
|
|
598
|
-
|
|
822
|
+
// An explicit semantic metric remains a metric even if an old index also
|
|
823
|
+
// carries an imprecise type. Conversely, a typed semantic time dimension
|
|
824
|
+
// such as `metric_time` must not become a metric merely because its local
|
|
825
|
+
// compiler name contains the word "metric". The latter was causing V2 to
|
|
826
|
+
// admit the time card as a metric and discard its declared grains before
|
|
827
|
+
// semantic validation.
|
|
828
|
+
const explicitMetricCandidate = candidate.kind === 'semantic_metric'
|
|
599
829
|
|| candidate.semanticObjectType === 'metric'
|
|
600
|
-
|| candidate.semanticObjectType === 'measure'
|
|
601
|
-
|
|
602
|
-
|
|
830
|
+
|| candidate.semanticObjectType === 'measure';
|
|
831
|
+
const sourceDeclaredTimeRole = !explicitMetricCandidate && candidateHasDeclaredTimeRole(candidate);
|
|
832
|
+
const metricCandidate = explicitMetricCandidate
|
|
833
|
+
|| (!sourceDeclaredTimeRole && (/\bmetric\b/.test(identity)
|
|
834
|
+
|| (physicalColumn && /\b(?:revenue|amount|count|rate|bcm|spend|cost|margin|total)\b/.test(identity))));
|
|
835
|
+
// A temporal type/grain is stronger than an authored compatibility-role
|
|
836
|
+
// label. Index migrations can leave an old `roles categorical dimension`
|
|
837
|
+
// fact on a date field, but allowing that contradictory fact back into the
|
|
838
|
+
// ordinary inference lane turns time fields into false geography choices.
|
|
839
|
+
// Metrics retain their explicit compatibility roles because capability
|
|
840
|
+
// metadata is not the metric object's own temporal identity.
|
|
841
|
+
const declaredTimeRole = !metricCandidate && sourceDeclaredTimeRole;
|
|
842
|
+
const legacyTimeName = !metricCandidate
|
|
843
|
+
&& !declaredTimeRole
|
|
844
|
+
&& candidateUsesLegacyTimeNameFallback(candidate);
|
|
845
|
+
const temporalCandidate = declaredTimeRole || legacyTimeName;
|
|
603
846
|
if (metricCandidate)
|
|
604
847
|
roles.add('metric');
|
|
605
848
|
// Capability metadata belongs to the metric's execution contract. It must
|
|
@@ -620,18 +863,38 @@ export function evidenceCandidateRoles(candidate) {
|
|
|
620
863
|
&& /\b(?:name|label|display)\b/.test(identity)
|
|
621
864
|
&& !hasEntityAttributeTerm(identity))
|
|
622
865
|
roles.add('entity_label');
|
|
623
|
-
if (
|
|
624
|
-
|| (candidate.timeGrains?.length ?? 0) > 0)
|
|
866
|
+
if (temporalCandidate)
|
|
625
867
|
roles.add('time_dimension');
|
|
626
868
|
if ((candidate.kind === 'dql_modeling' && (candidate.relationshipEvidence?.length ?? 0) > 0)
|
|
627
869
|
|| /\b(?:relationship|join|bridge)\b/.test(identity))
|
|
628
870
|
roles.add('relationship');
|
|
629
|
-
|
|
630
|
-
|
|
871
|
+
// `semantic_member` intentionally collapses dimensions, entities, models,
|
|
872
|
+
// and saved queries in older local indexes. Only a real (or legacy
|
|
873
|
+
// unclassified) dimension can be a categorical field. A semantic model or
|
|
874
|
+
// entity is execution context, never a user-visible grouping dimension.
|
|
875
|
+
const semanticDimension = candidate.semanticObjectType === 'dimension'
|
|
876
|
+
|| (candidate.kind === 'semantic_member' && candidate.semanticObjectType === undefined);
|
|
877
|
+
// A time dimension can be grouped at a time grain, but it is not an
|
|
878
|
+
// ordinary categorical/geographic alternative for a business term such
|
|
879
|
+
// as `region`. Keep the role sets mutually exclusive here; the semantic
|
|
880
|
+
// compiler still receives the same qualified identity when time is asked.
|
|
881
|
+
if (!temporalCandidate
|
|
882
|
+
&& (semanticDimension
|
|
883
|
+
|| (physicalColumn && /\b(?:competitor|region|category|segment|status|type|owner|sentiment|active|product|description)\b/.test(identity)))) {
|
|
631
884
|
roles.add('categorical_dimension');
|
|
885
|
+
}
|
|
886
|
+
if (candidate.kind === 'semantic_member' && candidate.semanticObjectType === 'member')
|
|
887
|
+
roles.add('member');
|
|
632
888
|
}
|
|
633
|
-
for (const role of explicitlyDeclaredCandidateRoles(candidate))
|
|
889
|
+
for (const role of explicitlyDeclaredCandidateRoles(candidate)) {
|
|
890
|
+
// Do not let a stale/contradictory authored compatibility declaration
|
|
891
|
+
// reverse a source-authored temporal type or legacy temporal identity.
|
|
892
|
+
// The temporal role remains visible; only the conflicting ordinary
|
|
893
|
+
// categorical role is rejected.
|
|
894
|
+
if (temporalCandidate && role === 'categorical_dimension')
|
|
895
|
+
continue;
|
|
634
896
|
roles.add(role);
|
|
897
|
+
}
|
|
635
898
|
if (candidate.kind === 'sql_column' || candidate.kind === 'dbt_model' || candidate.kind === 'sql_table')
|
|
636
899
|
roles.add('context');
|
|
637
900
|
if (roles.size === 0)
|
|
@@ -748,7 +1011,12 @@ export function candidateMatchesCategoricalDimensionRequirement(candidate, terms
|
|
|
748
1011
|
* to two candidates before relevance fills remaining cards.
|
|
749
1012
|
*/
|
|
750
1013
|
export function selectRoleBalancedMeaningCandidates(input) {
|
|
751
|
-
|
|
1014
|
+
// This selector is used twice by Ask Analyst Runtime: once to make the
|
|
1015
|
+
// immutable 32-item execution workspace and again to make the compact
|
|
1016
|
+
// 16-card planner package. Keep the ceiling here rather than allowing a
|
|
1017
|
+
// caller to accidentally turn a retrieval result into an unbounded prompt
|
|
1018
|
+
// or compiler closure.
|
|
1019
|
+
const max = Math.max(1, Math.min(32, Math.floor(input.maxCandidates ?? 16)));
|
|
752
1020
|
const ranked = [...new Map(input.candidates
|
|
753
1021
|
.filter((candidate) => candidate.id.trim() && candidate.compatibility !== 'incompatible')
|
|
754
1022
|
.map((candidate) => [candidate.id, candidate])).values()]
|
|
@@ -762,6 +1030,7 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
762
1030
|
if (selected.length < max && !selected.some((item) => item.id === candidate.id))
|
|
763
1031
|
selected.push(candidate);
|
|
764
1032
|
};
|
|
1033
|
+
const categoricalTerms = categoricalDimensionRequirementTerms(input.requirements);
|
|
765
1034
|
const servesRequestedRole = (candidate) => {
|
|
766
1035
|
const roles = evidenceCandidateRoles(candidate);
|
|
767
1036
|
const metricTerms = input.requirements.ranking?.metricTerms.length
|
|
@@ -772,6 +1041,8 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
772
1041
|
// An entity term such as "account" is deliberately insufficient for an
|
|
773
1042
|
// attribute (Account Owner Email) to displace the requested display key.
|
|
774
1043
|
// Only an actual entity-label candidate may satisfy this binding.
|
|
1044
|
+
if (roles.includes('entity_key') && candidateMatchesTerms(candidate, input.requirements.entityTerms))
|
|
1045
|
+
return true;
|
|
775
1046
|
if (roles.includes('entity_label') && candidateMatchesTerms(candidate, [
|
|
776
1047
|
...input.requirements.entityTerms,
|
|
777
1048
|
...input.requirements.entityDisplayTerms,
|
|
@@ -779,11 +1050,16 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
779
1050
|
return true;
|
|
780
1051
|
if (roles.includes('time_dimension') && Boolean(input.requirements.time))
|
|
781
1052
|
return true;
|
|
782
|
-
const categoricalTerms = categoricalDimensionRequirementTerms(input.requirements);
|
|
783
1053
|
if (roles.includes('categorical_dimension')
|
|
784
1054
|
&& categoricalTerms.length > 0
|
|
785
1055
|
&& candidateMatchesTerms(candidate, categoricalTerms, { categoricalDimension: true }))
|
|
786
1056
|
return true;
|
|
1057
|
+
if (roles.includes('member') && input.requirements.memberTerms.length > 0
|
|
1058
|
+
&& candidateMatchesTerms(candidate, input.requirements.memberTerms))
|
|
1059
|
+
return true;
|
|
1060
|
+
if (roles.includes('categorical_dimension') && input.requirements.memberTerms.length > 0
|
|
1061
|
+
&& candidateHasSafeValueForMemberTerms(candidate, input.requirements.memberTerms))
|
|
1062
|
+
return true;
|
|
787
1063
|
if (roles.includes('relationship')
|
|
788
1064
|
&& (input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0))
|
|
789
1065
|
return true;
|
|
@@ -798,13 +1074,19 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
798
1074
|
add(candidate);
|
|
799
1075
|
}
|
|
800
1076
|
const required = [
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1077
|
+
// An explicit ranking measure is never displaced by correlated metric
|
|
1078
|
+
// variants. Two cards leave room for a compatible canonical/alias pair.
|
|
1079
|
+
{ role: 'metric', terms: input.requirements.ranking?.metricTerms.length ? input.requirements.ranking.metricTerms : input.requirements.measures, limit: 2 },
|
|
1080
|
+
{ role: 'entity_key', terms: input.requirements.entityTerms, limit: 1 },
|
|
1081
|
+
{ role: 'entity_label', terms: [...input.requirements.entityTerms, ...input.requirements.entityDisplayTerms], limit: 2 },
|
|
1082
|
+
// Each requested breakdown gets two candidates, bounded to four total so
|
|
1083
|
+
// a product/category request cannot be crowded out by customer variants.
|
|
1084
|
+
...categoricalTerms.slice(0, 2).map((term) => ({ role: 'categorical_dimension', terms: [term], limit: 2, categorical: true })),
|
|
1085
|
+
{ role: 'time_dimension', terms: input.requirements.time ? [input.requirements.time.grain ?? 'time'] : [], limit: 2 },
|
|
1086
|
+
{ role: 'member', terms: input.requirements.memberTerms, limit: 2 },
|
|
1087
|
+
{ role: 'relationship', terms: input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0 ? ['relationship'] : [], limit: 2 },
|
|
806
1088
|
];
|
|
807
|
-
for (const
|
|
1089
|
+
for (const { role, terms, limit, categorical } of required) {
|
|
808
1090
|
// No requested categorical dimension means that high-scoring arbitrary
|
|
809
1091
|
// members are noise, not a role reservation. This is the subtle path that
|
|
810
1092
|
// used to admit Account Owner and Sentiment immediately after Account Name.
|
|
@@ -812,10 +1094,13 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
812
1094
|
continue;
|
|
813
1095
|
let admitted = 0;
|
|
814
1096
|
for (const candidate of ranked) {
|
|
815
|
-
if (admitted >=
|
|
1097
|
+
if (admitted >= limit || selected.length >= max)
|
|
816
1098
|
break;
|
|
817
1099
|
const roles = evidenceCandidateRoles(candidate);
|
|
818
|
-
|
|
1100
|
+
const safePhysicalMember = role === 'member'
|
|
1101
|
+
&& roles.includes('categorical_dimension')
|
|
1102
|
+
&& candidateHasSafeValueForMemberTerms(candidate, terms);
|
|
1103
|
+
if (!roles.includes(role) && !safePhysicalMember)
|
|
819
1104
|
continue;
|
|
820
1105
|
// "top accounts" needs the account display key, not any field whose
|
|
821
1106
|
// label happens to contain account. Once a display candidate is
|
|
@@ -841,7 +1126,8 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
841
1126
|
// For entity labels, role is more important than a lexical owner/email
|
|
842
1127
|
// hit. For all other roles, prefer an identity matching the requested
|
|
843
1128
|
// business term but retain a role candidate when the request is terse.
|
|
844
|
-
if (terms.length > 0 && !
|
|
1129
|
+
if (terms.length > 0 && !safePhysicalMember
|
|
1130
|
+
&& !candidateMatchesTerms(candidate, terms, { categoricalDimension: categorical === true })
|
|
845
1131
|
&& role !== 'time_dimension' && role !== 'relationship' && role !== 'entity_label')
|
|
846
1132
|
continue;
|
|
847
1133
|
add(candidate);
|
|
@@ -854,6 +1140,27 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
854
1140
|
}
|
|
855
1141
|
return selected;
|
|
856
1142
|
}
|
|
1143
|
+
/** Exact snapshot-value equality only; no lexical/synonym member matching. */
|
|
1144
|
+
function candidateHasSafeValueForMemberTerms(candidate, terms) {
|
|
1145
|
+
const values = candidate.safeValueEvidence ?? [];
|
|
1146
|
+
return terms.some((term) => {
|
|
1147
|
+
const normalized = normalizeRequirementTerm(term);
|
|
1148
|
+
return Boolean(normalized) && values.some((value) => normalizeRequirementTerm(value.normalizedValue ?? value.value ?? '') === normalized);
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
/**
|
|
1152
|
+
* Build the Ask execution workspace before planner admission. It is the
|
|
1153
|
+
* only helper that may create the 32-card closure; all later compiler paths
|
|
1154
|
+
* must intersect with this immutable result rather than reaching back into a
|
|
1155
|
+
* broad snapshot.
|
|
1156
|
+
*/
|
|
1157
|
+
export function selectRoleBalancedWorkspaceCandidates(input) {
|
|
1158
|
+
return selectRoleBalancedMeaningCandidates({
|
|
1159
|
+
candidates: input.candidates,
|
|
1160
|
+
requirements: input.requirements,
|
|
1161
|
+
maxCandidates: 32,
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
857
1164
|
export function classifyProviderFailure(input) {
|
|
858
1165
|
const text = `${input.code ?? ''} ${input.message ?? ''}`.toLowerCase();
|
|
859
1166
|
const cause = /cancel/.test(text) ? 'cancelled'
|
|
@@ -959,23 +1266,76 @@ export function splitAnalyticalTasks(question) {
|
|
|
959
1266
|
const parts = (asked.length > 0 ? asked : raw)
|
|
960
1267
|
.map((part) => part.replace(leadingJunk, '').replace(trailingJunk, '').trim())
|
|
961
1268
|
.filter(Boolean);
|
|
962
|
-
|
|
1269
|
+
// "… and give me top 5 rows" is a RANKING CLAUSE of the request before it,
|
|
1270
|
+
// not an independent question — it names no measure, no entity, nothing to
|
|
1271
|
+
// ask about on its own. Splitting it minted a task titled "give me top 5
|
|
1272
|
+
// rows" that could never resolve, and the phantom task then spent the
|
|
1273
|
+
// dispatch budget the real question needed. A fragment that is nothing but
|
|
1274
|
+
// shape vocabulary folds back into its predecessor.
|
|
1275
|
+
const merged = [];
|
|
1276
|
+
for (const part of parts) {
|
|
1277
|
+
if (merged.length > 0 && isPureShapeClause(part)) {
|
|
1278
|
+
merged[merged.length - 1] = `${merged[merged.length - 1]} and ${part}`;
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
merged.push(part);
|
|
1282
|
+
}
|
|
1283
|
+
return merged.length > 0 ? merged : [question.trim()];
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Does this fragment carry ONLY result-shape vocabulary (ranking, limit,
|
|
1287
|
+
* projection), with no subject of its own? Checked by removal: strip the
|
|
1288
|
+
* shape words, counts and connectives; a real question leaves a residue.
|
|
1289
|
+
*/
|
|
1290
|
+
function isPureShapeClause(fragment) {
|
|
1291
|
+
const lower = fragment.toLowerCase();
|
|
1292
|
+
if (!/\b(?:top|bottom|first|last|highest|lowest|limit|rows?|results?)\b/.test(lower))
|
|
1293
|
+
return false;
|
|
1294
|
+
const residue = lower
|
|
1295
|
+
.replace(/\b(?:and|then|please|give|me|show|list|just|only|the|a|an|top|bottom|first|last|highest|lowest|best|worst|limit|it|to|of|them|rows?|results?|records?|entries|items?|values?)\b/g, ' ')
|
|
1296
|
+
.replace(/\b(?:one|two|three|four|five|six|seven|eight|nine|ten|twelve)\b/g, ' ')
|
|
1297
|
+
.replace(/\d+/g, ' ')
|
|
1298
|
+
.replace(/[^a-z]+/g, ' ')
|
|
1299
|
+
.trim();
|
|
1300
|
+
return residue.length === 0;
|
|
963
1301
|
}
|
|
964
1302
|
export function buildAnalyticalTaskGraph(input) {
|
|
965
|
-
|
|
1303
|
+
// Research is an explicit mode boundary. Investigative wording in an
|
|
1304
|
+
// ordinary Ask can influence its operations but must not silently switch it
|
|
1305
|
+
// to the multi-branch Research budget/execution contract.
|
|
1306
|
+
const inferredKind = inferAnalyticalTurnKind(input.question);
|
|
1307
|
+
const rootKind = input.mode === 'research'
|
|
1308
|
+
? 'research'
|
|
1309
|
+
: inferredKind === 'research'
|
|
1310
|
+
? 'diagnosis'
|
|
1311
|
+
: inferredKind;
|
|
966
1312
|
// A research turn may later create bounded evidence branches, but that is a
|
|
967
1313
|
// research planner's job. Splitting at ingress loses the surrounding story
|
|
968
1314
|
// before it has an opportunity to reason about it.
|
|
969
|
-
const
|
|
970
|
-
|
|
1315
|
+
const sourceClauses = rootKind === 'research'
|
|
1316
|
+
? [input.question.trim()]
|
|
1317
|
+
: splitAnalyticalTasks(input.question);
|
|
1318
|
+
const taskCap = Math.max(1, Math.min(6, input.maxTasks ?? 6));
|
|
1319
|
+
// Keep the overflow visible to the runtime. The prior `slice()` silently
|
|
1320
|
+
// accepted the first three ordinary-Ask clauses and returned `partial:
|
|
1321
|
+
// false`, which could make a four-question request look successfully
|
|
1322
|
+
// answered after only three frozen programs. Ordinary Ask must stop before
|
|
1323
|
+
// planning/execution in that case; explicit Research owns broader branching.
|
|
1324
|
+
const partial = sourceClauses.length > taskCap;
|
|
1325
|
+
const clauses = sourceClauses.slice(0, taskCap);
|
|
971
1326
|
const candidateIds = [...new Set((input.candidateIds ?? []).filter((id) => id.trim()))];
|
|
972
1327
|
const metrics = [...new Set((input.metrics ?? []).filter((metric) => metric.trim()))];
|
|
973
1328
|
const dimensions = [...new Set((input.dimensions ?? []).filter((dimension) => dimension.trim()))];
|
|
974
1329
|
const filters = input.filters ?? [];
|
|
975
1330
|
const inheritedBindings = input.inheritedBindings ?? [];
|
|
976
1331
|
const unboundTasks = clauses.map((clause, index) => {
|
|
977
|
-
const
|
|
978
|
-
const
|
|
1332
|
+
const inferredClauseKind = rootKind === 'research' ? 'research' : inferAnalyticalTurnKind(clause);
|
|
1333
|
+
const kind = rootKind === 'research'
|
|
1334
|
+
? 'research'
|
|
1335
|
+
: inferredClauseKind === 'research'
|
|
1336
|
+
? 'diagnosis'
|
|
1337
|
+
: inferredClauseKind;
|
|
1338
|
+
const research = rootKind === 'research';
|
|
979
1339
|
const taskKind = research
|
|
980
1340
|
? 'research_branch'
|
|
981
1341
|
: kind === 'lookup'
|
|
@@ -1010,7 +1370,7 @@ export function buildAnalyticalTaskGraph(input) {
|
|
|
1010
1370
|
return {
|
|
1011
1371
|
kind: rootKind === 'research' ? 'research' : tasks.length > 1 ? 'compound' : rootKind,
|
|
1012
1372
|
tasks,
|
|
1013
|
-
partial
|
|
1373
|
+
partial,
|
|
1014
1374
|
};
|
|
1015
1375
|
}
|
|
1016
1376
|
const TOP_RANKED_REGION_RE = /(?:\b(?:top|highest|most)\b[^?.!]{0,72}\bregions?\b|\bregions?\b[^?.!]{0,72}\b(?:top|highest|most)\b)/i;
|
|
@@ -1400,6 +1760,89 @@ export async function retrieveContextLanes(lanes, limit = 32, maxConcurrent = 4)
|
|
|
1400
1760
|
}
|
|
1401
1761
|
return fused;
|
|
1402
1762
|
}
|
|
1763
|
+
function ledgerFactIdsV3(branchId, facts) {
|
|
1764
|
+
const byFact = new Map();
|
|
1765
|
+
const ids = [];
|
|
1766
|
+
for (const fact of facts) {
|
|
1767
|
+
const normalized = fact.trim();
|
|
1768
|
+
if (!normalized || byFact.has(normalized))
|
|
1769
|
+
continue;
|
|
1770
|
+
const id = `fact:${branchId}:${ids.length + 1}`;
|
|
1771
|
+
ids.push(id);
|
|
1772
|
+
byFact.set(normalized, id);
|
|
1773
|
+
}
|
|
1774
|
+
return { ids, byFact };
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* Build a mixed V3 ledger without reinterpreting V1/V2. The legacy ledgers
|
|
1778
|
+
* continue to contain analytical branches only, because their `observed`
|
|
1779
|
+
* state requires an execution/result receipt and would otherwise falsely
|
|
1780
|
+
* represent a graph walk as query execution.
|
|
1781
|
+
*/
|
|
1782
|
+
export function buildResearchEvidenceLedgerV3(input) {
|
|
1783
|
+
const entries = input.entries
|
|
1784
|
+
.slice(0, 6)
|
|
1785
|
+
.sort((left, right) => left.index - right.index || left.kind.localeCompare(right.kind))
|
|
1786
|
+
.map((source) => {
|
|
1787
|
+
if (source.kind === 'analytical_result') {
|
|
1788
|
+
const entry = source.entry;
|
|
1789
|
+
const facts = ledgerFactIdsV3(entry.branchId, entry.facts);
|
|
1790
|
+
const counterEvidenceFactIds = [...new Set(entry.counterEvidenceFactIds)]
|
|
1791
|
+
.flatMap((fact) => facts.byFact.get(fact) ? [facts.byFact.get(fact)] : []);
|
|
1792
|
+
const resultFingerprint = normalizeAnalyticalExecutionFingerprint(entry.resultFingerprint);
|
|
1793
|
+
return {
|
|
1794
|
+
version: 3,
|
|
1795
|
+
id: entry.id,
|
|
1796
|
+
branchId: entry.branchId,
|
|
1797
|
+
evidenceKind: 'analytical_result',
|
|
1798
|
+
status: entry.status,
|
|
1799
|
+
verdict: entry.verdict,
|
|
1800
|
+
...(source.hypothesisFingerprint ? { hypothesisFingerprint: source.hypothesisFingerprint } : {}),
|
|
1801
|
+
factIds: facts.ids,
|
|
1802
|
+
counterEvidenceFactIds,
|
|
1803
|
+
receiptFingerprints: resultFingerprint ? [resultFingerprint] : [],
|
|
1804
|
+
...(resultFingerprint ? { resultFingerprint } : {}),
|
|
1805
|
+
};
|
|
1806
|
+
}
|
|
1807
|
+
const facts = ledgerFactIdsV3(source.branchId, [
|
|
1808
|
+
`lineage:${source.receipt.status}`,
|
|
1809
|
+
`lineage:resolution:${source.receipt.resolution}`,
|
|
1810
|
+
]);
|
|
1811
|
+
const status = source.status
|
|
1812
|
+
?? (source.receipt.status === 'completed' || source.receipt.status === 'truncated'
|
|
1813
|
+
? 'observed'
|
|
1814
|
+
: 'failed');
|
|
1815
|
+
return {
|
|
1816
|
+
version: 3,
|
|
1817
|
+
id: source.id,
|
|
1818
|
+
branchId: source.branchId,
|
|
1819
|
+
evidenceKind: 'lineage_graph',
|
|
1820
|
+
status,
|
|
1821
|
+
// The local graph can establish a structural dependency. It cannot
|
|
1822
|
+
// establish causation, even when a complete path exists.
|
|
1823
|
+
verdict: status === 'skipped' ? 'skipped' : 'inconclusive',
|
|
1824
|
+
...(source.hypothesisFingerprint ? { hypothesisFingerprint: source.hypothesisFingerprint } : {}),
|
|
1825
|
+
factIds: facts.ids,
|
|
1826
|
+
counterEvidenceFactIds: [],
|
|
1827
|
+
receiptFingerprints: source.receipt.structuralFingerprint ? [source.receipt.structuralFingerprint] : [],
|
|
1828
|
+
lineageReceipt: source.receipt,
|
|
1829
|
+
};
|
|
1830
|
+
});
|
|
1831
|
+
const observedGroundableBranchCount = entries.filter((entry) => entry.status === 'observed' && entry.verdict !== 'skipped').length;
|
|
1832
|
+
const plannedGroundableBranchCount = Math.max(0, Math.min(6, Math.trunc(input.groundableBranchCount ?? 0)));
|
|
1833
|
+
const groundableBranchCount = Math.max(observedGroundableBranchCount, plannedGroundableBranchCount);
|
|
1834
|
+
return {
|
|
1835
|
+
version: 3,
|
|
1836
|
+
rootQuestionFingerprint: input.rootQuestionFingerprint,
|
|
1837
|
+
...(input.planId ? { planId: input.planId } : {}),
|
|
1838
|
+
...(input.snapshotId ? { snapshotId: input.snapshotId } : {}),
|
|
1839
|
+
entries,
|
|
1840
|
+
factIds: [...new Set(entries.flatMap((entry) => [...entry.factIds, ...entry.counterEvidenceFactIds]))],
|
|
1841
|
+
groundableBranchCount,
|
|
1842
|
+
limitedScope: groundableBranchCount < 3,
|
|
1843
|
+
stoppingReason: input.stoppingReason ?? (entries.length > 0 ? 'completed' : 'not_started'),
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1403
1846
|
export function capResearchBranches(branches, max = 6) {
|
|
1404
1847
|
return branches.slice(0, Math.max(1, Math.min(6, Math.trunc(max))));
|
|
1405
1848
|
}
|