@duckcodeailabs/dql-agent 1.14.2 → 1.14.3-rc.1
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 +63 -2
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +864 -29
- package/dist/agent-run-engine.js.map +1 -1
- package/dist/agent-run-planner.d.ts +6 -0
- package/dist/agent-run-planner.d.ts.map +1 -1
- package/dist/agent-run-planner.js +2 -0
- package/dist/agent-run-planner.js.map +1 -1
- package/dist/agent-run-store.d.ts.map +1 -1
- package/dist/agent-run-store.js +48 -3
- package/dist/agent-run-store.js.map +1 -1
- package/dist/agentic/analyst-loop.d.ts +7 -1
- package/dist/agentic/analyst-loop.d.ts.map +1 -1
- package/dist/agentic/analyst-loop.js +163 -0
- package/dist/agentic/analyst-loop.js.map +1 -1
- package/dist/agentic/assumptions.d.ts +1 -1
- package/dist/agentic/assumptions.d.ts.map +1 -1
- package/dist/agentic/assumptions.js.map +1 -1
- package/dist/agentic/research-agent.d.ts.map +1 -1
- package/dist/agentic/research-agent.js +10 -1
- package/dist/agentic/research-agent.js.map +1 -1
- package/dist/agentic/sql-authorization.d.ts +8 -0
- package/dist/agentic/sql-authorization.d.ts.map +1 -1
- package/dist/agentic/sql-authorization.js +3 -0
- package/dist/agentic/sql-authorization.js.map +1 -1
- package/dist/analytical-compatibility.d.ts.map +1 -1
- package/dist/analytical-compatibility.js +8 -1
- package/dist/analytical-compatibility.js.map +1 -1
- package/dist/analytical-failure-repair.d.ts.map +1 -1
- package/dist/analytical-failure-repair.js +6 -2
- package/dist/analytical-failure-repair.js.map +1 -1
- package/dist/analytical-frame.d.ts +23 -1
- package/dist/analytical-frame.d.ts.map +1 -1
- package/dist/analytical-frame.js +322 -16
- package/dist/analytical-frame.js.map +1 -1
- package/dist/analytical-orchestration.d.ts +314 -3
- package/dist/analytical-orchestration.d.ts.map +1 -1
- package/dist/analytical-orchestration.js +486 -43
- package/dist/analytical-orchestration.js.map +1 -1
- package/dist/answer-loop.d.ts +64 -8
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +528 -67
- package/dist/answer-loop.js.map +1 -1
- package/dist/ask-observability/index.d.ts +11 -0
- package/dist/ask-observability/index.d.ts.map +1 -0
- package/dist/ask-observability/index.js +7 -0
- package/dist/ask-observability/index.js.map +1 -0
- package/dist/ask-observability/instrumentation.d.ts +35 -0
- package/dist/ask-observability/instrumentation.d.ts.map +1 -0
- package/dist/ask-observability/instrumentation.js +472 -0
- package/dist/ask-observability/instrumentation.js.map +1 -0
- package/dist/ask-observability/observer.d.ts +62 -0
- package/dist/ask-observability/observer.d.ts.map +1 -0
- package/dist/ask-observability/observer.js +537 -0
- package/dist/ask-observability/observer.js.map +1 -0
- package/dist/ask-observability/portable.d.ts +98 -0
- package/dist/ask-observability/portable.d.ts.map +1 -0
- package/dist/ask-observability/portable.js +527 -0
- package/dist/ask-observability/portable.js.map +1 -0
- package/dist/ask-observability/store.d.ts +130 -0
- package/dist/ask-observability/store.d.ts.map +1 -0
- package/dist/ask-observability/store.js +1114 -0
- package/dist/ask-observability/store.js.map +1 -0
- package/dist/ask-observability/types.d.ts +357 -0
- package/dist/ask-observability/types.d.ts.map +1 -0
- package/dist/ask-observability/types.js +2 -0
- package/dist/ask-observability/types.js.map +1 -0
- package/dist/ask-observability/utils.d.ts +21 -0
- package/dist/ask-observability/utils.d.ts.map +1 -0
- package/dist/ask-observability/utils.js +102 -0
- package/dist/ask-observability/utils.js.map +1 -0
- package/dist/generated-analytical-proposal.d.ts +37 -1
- package/dist/generated-analytical-proposal.d.ts.map +1 -1
- package/dist/generated-analytical-proposal.js +211 -3
- package/dist/generated-analytical-proposal.js.map +1 -1
- package/dist/governed-relational-compiler.d.ts +15 -0
- package/dist/governed-relational-compiler.d.ts.map +1 -1
- package/dist/governed-relational-compiler.js +99 -1
- package/dist/governed-relational-compiler.js.map +1 -1
- package/dist/index.d.ts +9 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +10 -0
- 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/meaning-resolution.d.ts +78 -6
- package/dist/meaning-resolution.d.ts.map +1 -1
- package/dist/meaning-resolution.js +331 -38
- package/dist/meaning-resolution.js.map +1 -1
- package/dist/metadata/analysis-planner.d.ts.map +1 -1
- package/dist/metadata/analysis-planner.js +21 -1
- package/dist/metadata/analysis-planner.js.map +1 -1
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +1 -1
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/meaning-evidence.d.ts +9 -2
- package/dist/metadata/meaning-evidence.d.ts.map +1 -1
- package/dist/metadata/meaning-evidence.js +261 -11
- package/dist/metadata/meaning-evidence.js.map +1 -1
- package/dist/plan-execution-adapter.d.ts +9 -1
- package/dist/plan-execution-adapter.d.ts.map +1 -1
- package/dist/plan-execution-adapter.js +93 -13
- package/dist/plan-execution-adapter.js.map +1 -1
- package/dist/provider-egress.d.ts +24 -10
- package/dist/provider-egress.d.ts.map +1 -1
- package/dist/provider-egress.js +22 -17
- package/dist/provider-egress.js.map +1 -1
- package/dist/providers/claude.d.ts.map +1 -1
- package/dist/providers/claude.js +10 -9
- package/dist/providers/claude.js.map +1 -1
- package/dist/providers/dispatch.d.ts +27 -1
- package/dist/providers/dispatch.d.ts.map +1 -1
- package/dist/providers/dispatch.js +63 -1
- package/dist/providers/dispatch.js.map +1 -1
- package/dist/providers/gemini.js +10 -10
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/index.d.ts +2 -2
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +1 -1
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/ollama.js +15 -15
- package/dist/providers/ollama.js.map +1 -1
- package/dist/providers/openai.js +22 -22
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/types.d.ts +52 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/relationship-proof.d.ts +81 -0
- package/dist/relationship-proof.d.ts.map +1 -1
- package/dist/relationship-proof.js +228 -4
- package/dist/relationship-proof.js.map +1 -1
- package/dist/resolved-analytical-plan.d.ts +18 -4
- package/dist/resolved-analytical-plan.d.ts.map +1 -1
- package/dist/resolved-analytical-plan.js +764 -47
- package/dist/resolved-analytical-plan.js.map +1 -1
- package/dist/router.d.ts +10 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +1785 -213
- package/dist/router.js.map +1 -1
- package/dist/semantic-bridge/compose.d.ts.map +1 -1
- package/dist/semantic-bridge/compose.js +23 -4
- package/dist/semantic-bridge/compose.js.map +1 -1
- package/package.json +4 -4
|
@@ -13,6 +13,133 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
15
|
export const ANALYTICAL_ORCHESTRATION_CONTRACT_VERSION = 1;
|
|
16
|
+
/** Build the immutable host request tuple before a meaning model can respond. */
|
|
17
|
+
export function buildAnalyticalRequirementSeedV1(input) {
|
|
18
|
+
// Retrieval/parser output is intentionally broad: it may contain useful
|
|
19
|
+
// context from a prior turn, vector hit, or search expansion. It is not an
|
|
20
|
+
// authority for a new free-text request. Keep only refinements that the
|
|
21
|
+
// source question itself demonstrates before they can contribute to the
|
|
22
|
+
// frozen host tuple. Structured clarification selections are merged by the
|
|
23
|
+
// router into `requirements` before this function is called.
|
|
24
|
+
const parsed = currentQuestionGroundedParsedIntent(input.question, input.parsedIntent);
|
|
25
|
+
const requirements = input.requirements ?? buildAnalyticalRequirementSet({
|
|
26
|
+
question: input.question,
|
|
27
|
+
parsedIntent: parsed,
|
|
28
|
+
});
|
|
29
|
+
// Order and limit are lexical requirements, not parser defaults. In
|
|
30
|
+
// particular, a prior ranking must not turn a complete new question into a
|
|
31
|
+
// top-N query just because retrieval retained an old `limit` or `order`.
|
|
32
|
+
const order = requirements.ranking
|
|
33
|
+
? requirements.ranking.direction === 'bottom' ? 'asc' : 'desc'
|
|
34
|
+
: undefined;
|
|
35
|
+
const limit = requirements.ranking?.limit;
|
|
36
|
+
const filters = [...(parsed?.filters ?? [])].map((filter) => ({ field: filter.field, value: filter.value }));
|
|
37
|
+
if (requirements.time?.fiscalPeriod && input.fiscalCalendar?.fiscalPeriodFieldId
|
|
38
|
+
&& !filters.some((filter) => filter.field === input.fiscalCalendar.fiscalPeriodFieldId)) {
|
|
39
|
+
filters.push({ field: input.fiscalCalendar.fiscalPeriodFieldId, value: requirements.time.fiscalPeriod });
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
version: 1,
|
|
43
|
+
sourceQuestion: input.question,
|
|
44
|
+
requirements,
|
|
45
|
+
queryIntent: {
|
|
46
|
+
measures: [...requirements.measures],
|
|
47
|
+
// A ranking's entity display key is an execution requirement, not a
|
|
48
|
+
// prompt nicety. Keep it in the host-owned query tuple, but do not put
|
|
49
|
+
// the broad entity noun (for example `customer`) in the categorical
|
|
50
|
+
// dimension lane. A metric can legitimately expose customer type and
|
|
51
|
+
// customer order number as groupings; neither is interchangeable with
|
|
52
|
+
// the requested customer display/rank key. The frame resolves the
|
|
53
|
+
// display term against the selected metric's native display/rank role.
|
|
54
|
+
dimensions: [...new Set([
|
|
55
|
+
...categoricalDimensionRequirementTerms(requirements),
|
|
56
|
+
...requirements.entityDisplayTerms,
|
|
57
|
+
])],
|
|
58
|
+
filters,
|
|
59
|
+
...(parsed?.timeRange ? { timeRange: parsed.timeRange } : {}),
|
|
60
|
+
...(requirements.time?.grain
|
|
61
|
+
? { timeGrain: requirements.time.grain }
|
|
62
|
+
: parsed?.timeGrain ? { timeGrain: parsed.timeGrain } : {}),
|
|
63
|
+
...(order ? { order } : {}),
|
|
64
|
+
...(limit !== undefined ? { limit } : {}),
|
|
65
|
+
// A calendar/date role is declared snapshot metadata, never an ID
|
|
66
|
+
// copied from retrieval/parser evidence.
|
|
67
|
+
...(input.fiscalCalendar?.id ? { fiscalCalendarId: input.fiscalCalendar.id } : {}),
|
|
68
|
+
...(input.fiscalCalendar?.dateRoleId ? { fiscalDateRoleId: input.fiscalCalendar.dateRoleId } : {}),
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Return only parser refinements whose business words occur in the current
|
|
74
|
+
* source question. This deliberately does not try to recover previous turn
|
|
75
|
+
* context: continuation is represented by a server-issued structured choice
|
|
76
|
+
* and merged separately by the host. The helper is exported for regression
|
|
77
|
+
* tests and for router paths that construct a requirement set before a seed.
|
|
78
|
+
*/
|
|
79
|
+
export function currentQuestionGroundedParsedIntent(question, parsedIntent) {
|
|
80
|
+
if (!parsedIntent)
|
|
81
|
+
return undefined;
|
|
82
|
+
// These are intentionally tiny, product-wide vocabulary aliases rather
|
|
83
|
+
// than semantic guessing. They let a retrieval parser retain the same
|
|
84
|
+
// current-turn business phrase (`drink revenue` for `beverage revenue`, or
|
|
85
|
+
// `sales` for `revenue`) while still rejecting an unrelated prior-turn
|
|
86
|
+
// phrase such as `rollover balance`. Do not add customer/model-specific
|
|
87
|
+
// synonyms here: those require a qualified selected candidate and an
|
|
88
|
+
// override receipt at the meaning boundary.
|
|
89
|
+
const canonicalGroundingToken = (term) => {
|
|
90
|
+
if (term === 'sale' || term === 'sales')
|
|
91
|
+
return 'revenue';
|
|
92
|
+
if (term === 'drink' || term === 'drinks')
|
|
93
|
+
return 'beverage';
|
|
94
|
+
return term;
|
|
95
|
+
};
|
|
96
|
+
const questionTerms = new Set(normalizeRequirementTerm(question)
|
|
97
|
+
.split(' ')
|
|
98
|
+
.filter((term) => term.length > 1)
|
|
99
|
+
.map(canonicalGroundingToken));
|
|
100
|
+
const groundedTerm = (value) => {
|
|
101
|
+
const terms = normalizeRequirementTerm(value ?? '')
|
|
102
|
+
.split(' ')
|
|
103
|
+
.filter((term) => term.length > 1 && !/^(?:the|a|an|by|for|with|and|or|of|to|in|on|at)$/.test(term))
|
|
104
|
+
.map(canonicalGroundingToken);
|
|
105
|
+
return terms.length > 0 && terms.every((term) => {
|
|
106
|
+
if (questionTerms.has(term))
|
|
107
|
+
return true;
|
|
108
|
+
// Preserve only a trivial singular/plural normalization. Anything more
|
|
109
|
+
// permissive would let a stale retrieved phrase become a new request.
|
|
110
|
+
return term.endsWith('s')
|
|
111
|
+
? questionTerms.has(term.slice(0, -1))
|
|
112
|
+
: questionTerms.has(`${term}s`);
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
const timeRangeGrounded = (value) => {
|
|
116
|
+
const normalized = normalizeRequirementTerm(value ?? '');
|
|
117
|
+
if (!normalized)
|
|
118
|
+
return false;
|
|
119
|
+
if (groundedTerm(normalized))
|
|
120
|
+
return true;
|
|
121
|
+
return /^(?:last|previous|past) \d+ (?:day|days|week|weeks|month|months|quarter|quarters|year|years)$/.test(normalized)
|
|
122
|
+
&& normalizeRequirementTerm(question).includes(normalized);
|
|
123
|
+
};
|
|
124
|
+
const measures = (parsedIntent.measures ?? []).filter(groundedTerm);
|
|
125
|
+
const dimensions = (parsedIntent.dimensions ?? []).filter(groundedTerm);
|
|
126
|
+
// A parser may contribute the column that a reader's explicitly named value
|
|
127
|
+
// belongs to, but the value itself must appear in this request. This drops
|
|
128
|
+
// stale rollover-balance/member filters while retaining a current named
|
|
129
|
+
// member that the host can bind to a qualified field.
|
|
130
|
+
const filters = (parsedIntent.filters ?? []).filter((filter) => groundedTerm(filter.value));
|
|
131
|
+
const timeRange = timeRangeGrounded(parsedIntent.timeRange)
|
|
132
|
+
? parsedIntent.timeRange
|
|
133
|
+
: undefined;
|
|
134
|
+
return {
|
|
135
|
+
...(measures.length > 0 ? { measures } : {}),
|
|
136
|
+
...(dimensions.length > 0 ? { dimensions } : {}),
|
|
137
|
+
...(filters.length > 0 ? { filters } : {}),
|
|
138
|
+
...(timeRange ? { timeRange } : {}),
|
|
139
|
+
// Grain/ranking/limit are derived deterministically from the source
|
|
140
|
+
// question below. Never promote parser values by themselves.
|
|
141
|
+
};
|
|
142
|
+
}
|
|
16
143
|
/**
|
|
17
144
|
* Small, explainable intent vocabulary used before the physical plan exists.
|
|
18
145
|
* It is deliberately not a second semantic matcher: candidate identity still
|
|
@@ -54,6 +181,42 @@ function uniqueRequirementTerms(values) {
|
|
|
54
181
|
.map(normalizeRequirementTerm)
|
|
55
182
|
.filter(Boolean))];
|
|
56
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Entity/display terms are represented in the historical `dimensions` seed
|
|
186
|
+
* so ranking plans can retain their requested grain. They must not consume
|
|
187
|
+
* the separate categorical-dimension admission lane. For example, in "top
|
|
188
|
+
* customers by product category", `customer` is the entity/rank role while
|
|
189
|
+
* `product category` is the required categorical grouping role.
|
|
190
|
+
*/
|
|
191
|
+
export function categoricalDimensionRequirementTerms(requirements) {
|
|
192
|
+
const entityTerms = new Set(uniqueRequirementTerms([
|
|
193
|
+
...requirements.entityTerms,
|
|
194
|
+
...requirements.entityDisplayTerms,
|
|
195
|
+
]));
|
|
196
|
+
return uniqueRequirementTerms(requirements.dimensions)
|
|
197
|
+
.filter((term) => !entityTerms.has(term));
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* A small, typed vocabulary bridge for categorical field identities. It is
|
|
201
|
+
* intentionally not a general synonym engine: only the field-kind suffix is
|
|
202
|
+
* canonicalized, while the scoped business noun must still match. Thus
|
|
203
|
+
* `product category` can bind the snapshot-declared `product_type`, whereas
|
|
204
|
+
* `customer_type` cannot satisfy it. A bare `category` remains potentially
|
|
205
|
+
* ambiguous when more than one qualified `*_type` field exists.
|
|
206
|
+
*/
|
|
207
|
+
export function categoricalDimensionTermsMatch(left, right) {
|
|
208
|
+
const normalizedLeft = normalizeRequirementTerm(left);
|
|
209
|
+
const normalizedRight = normalizeRequirementTerm(right);
|
|
210
|
+
if (!normalizedLeft || !normalizedRight)
|
|
211
|
+
return false;
|
|
212
|
+
const phraseMatch = (a, b) => a === b || a.endsWith(` ${b}`) || b.endsWith(` ${a}`);
|
|
213
|
+
if (phraseMatch(normalizedLeft, normalizedRight))
|
|
214
|
+
return true;
|
|
215
|
+
const canonicalizeKind = (value) => value
|
|
216
|
+
.replace(/\bcategories\b/g, 'type')
|
|
217
|
+
.replace(/\bcategory\b/g, 'type');
|
|
218
|
+
return phraseMatch(canonicalizeKind(normalizedLeft), canonicalizeKind(normalizedRight));
|
|
219
|
+
}
|
|
57
220
|
function isTemporalTerm(term) {
|
|
58
221
|
return /^(?:date|day|week|month|quarter|year|fy\d{2,4}|fiscal year|fiscal quarter)$/.test(term);
|
|
59
222
|
}
|
|
@@ -112,6 +275,43 @@ function normalizedTypedAggregationRequirements(input) {
|
|
|
112
275
|
]);
|
|
113
276
|
return { measures, dimensions };
|
|
114
277
|
}
|
|
278
|
+
/**
|
|
279
|
+
* Keep grammatical wrappers out of the physical tuple. In particular, a
|
|
280
|
+
* parser can return `sales based on the region` as a dimension for a simple
|
|
281
|
+
* revenue-by-region request. That is neither a business dimension nor an
|
|
282
|
+
* object DQL may report as absent.
|
|
283
|
+
*/
|
|
284
|
+
function normalizeAnalyticalDimensionTerms(question, values) {
|
|
285
|
+
const hasRegion = /\b(?:by|based\s+on(?:\s+the)?|across|per)\s+(?:the\s+)?region\b/i.test(question);
|
|
286
|
+
const hasProductCategory = /\bproduct\s+categor(?:y|ies)\b/i.test(question);
|
|
287
|
+
// A planner can surface the noun from a projected field as a grouping
|
|
288
|
+
// dimension (for example `product` from “with product ID and product
|
|
289
|
+
// price”). An output is not a `by product` group. Keep the noun only when
|
|
290
|
+
// the reader actually supplied a grouping construction; otherwise the
|
|
291
|
+
// host-owned row-level/output tuple would acquire a fake dimension and
|
|
292
|
+
// make an otherwise single-table exploratory plan ambiguous.
|
|
293
|
+
const outputRoots = new Set(explicitOutputTerms(question)
|
|
294
|
+
.map((term) => term.replace(/\s+(?:id|name|price)$/i, '').trim())
|
|
295
|
+
.filter(Boolean));
|
|
296
|
+
const isExplicitGroupingRoot = (term) => {
|
|
297
|
+
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
298
|
+
return new RegExp(`\\b(?:by|per|across|for\\s+each)\\s+(?:the\\s+)?${escaped}(?:s)?\\b`, 'i').test(question);
|
|
299
|
+
};
|
|
300
|
+
const normalized = uniqueRequirementTerms([...values])
|
|
301
|
+
.filter((value) => !/\b(?:revenue|sales)\b.*\b(?:based\s+on|by)\b.*\bregion\b/.test(value))
|
|
302
|
+
.filter((value) => !(hasProductCategory && /^(?:product|category)$/.test(value)))
|
|
303
|
+
.filter((value) => !outputRoots.has(value) || isExplicitGroupingRoot(value));
|
|
304
|
+
return uniqueRequirementTerms([
|
|
305
|
+
...normalized,
|
|
306
|
+
...(hasRegion ? ['region'] : []),
|
|
307
|
+
...(hasProductCategory ? ['product category'] : []),
|
|
308
|
+
]);
|
|
309
|
+
}
|
|
310
|
+
function explicitOutputTerms(question) {
|
|
311
|
+
const terms = [...question.matchAll(/\b(?:order|product|customer|account)\s+(?:id|name|price)\b/gi)]
|
|
312
|
+
.map((match) => match[0] ?? '');
|
|
313
|
+
return uniqueRequirementTerms(terms);
|
|
314
|
+
}
|
|
115
315
|
/**
|
|
116
316
|
* Normalize grammatical aggregation wrappers before they become a plan
|
|
117
317
|
* requirement. Retrieval/parser output is allowed to retain useful search
|
|
@@ -127,7 +327,16 @@ function normalizedTypedAggregationRequirements(input) {
|
|
|
127
327
|
export function normalizeAnalyticalMeasureTerms(question, values, options = {}) {
|
|
128
328
|
const normalizedQuestion = normalizeRequirementTerm(question);
|
|
129
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
|
+
// A parser often singularizes the business alias `sales` into `sale`. That
|
|
331
|
+
// is not a second measure beside revenue: it is the same current-question
|
|
332
|
+
// request. Canonicalize only that standalone vocabulary alias here; named
|
|
333
|
+
// measures such as `sales_tax` or `sales_pipeline` keep their identity.
|
|
334
|
+
const canonicalMeasureAlias = (value) => {
|
|
335
|
+
const normalized = normalizeRequirementTerm(value);
|
|
336
|
+
return normalized === 'sale' || normalized === 'sales' ? 'revenue' : value;
|
|
337
|
+
};
|
|
130
338
|
const terms = values
|
|
339
|
+
.map(canonicalMeasureAlias)
|
|
131
340
|
.filter((value) => !isStructuralMeasurePhrase(value))
|
|
132
341
|
.filter((value) => !(groupedOrderCount && /^(?:order|orders)$/i.test(normalizeRequirementTerm(value))));
|
|
133
342
|
if (groupedOrderCount && !terms.some((value) => normalizeRequirementTerm(value) === 'count')) {
|
|
@@ -172,6 +381,43 @@ function nonRedundantLexicalMeasureTerms(parsedMeasures, question) {
|
|
|
172
381
|
.some((word) => word === token || (token === 'refund' && word === 'refunds')));
|
|
173
382
|
});
|
|
174
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* A ranking question can contain a business qualifier immediately before the
|
|
386
|
+
* ranked entity (for example, "BCM customers") as well as the measure that
|
|
387
|
+
* actually orders the result ("highest revenue"). The retrieval parser keeps
|
|
388
|
+
* both phrases because both are useful for recall, but they are not equivalent
|
|
389
|
+
* plan requirements. Prefer a direct ranking clause over the broad parser
|
|
390
|
+
* hint before any candidate is admitted as a metric.
|
|
391
|
+
*
|
|
392
|
+
* This deliberately remains narrow. It only disambiguates when the parser
|
|
393
|
+
* supplied competing measures and the user also wrote an explicit comparator;
|
|
394
|
+
* parser-absent and already-unambiguous ranking requests retain their existing
|
|
395
|
+
* normal meaning-resolution path.
|
|
396
|
+
*/
|
|
397
|
+
function explicitRankingMeasureTerms(question, parsedMeasures) {
|
|
398
|
+
const parsed = uniqueRequirementTerms([...parsedMeasures]);
|
|
399
|
+
if (parsed.length < 2)
|
|
400
|
+
return [];
|
|
401
|
+
const phrases = [];
|
|
402
|
+
const endOfMeasure = String.raw `(?=\s+(?:across|among|for|per|in|where|during|over|with|that|which|who|and|or)\b|[?.!,;]|$)`;
|
|
403
|
+
for (const pattern of [
|
|
404
|
+
new RegExp(String.raw `\b(?:highest|lowest|most|least)\s+(?:the\s+)?([a-z][a-z0-9_. -]{0,80}?)${endOfMeasure}`, 'gi'),
|
|
405
|
+
]) {
|
|
406
|
+
for (const match of question.matchAll(pattern)) {
|
|
407
|
+
const phrase = normalizeRequirementTerm(match[1] ?? '');
|
|
408
|
+
if (phrase && !isTemporalTerm(phrase))
|
|
409
|
+
phrases.push(phrase);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
const direct = uniqueRequirementTerms(phrases);
|
|
413
|
+
if (direct.length === 0)
|
|
414
|
+
return [];
|
|
415
|
+
// Preserve the parser's more stable authored phrase when it is the same
|
|
416
|
+
// measure. This avoids replacing `net revenue` with a looser lexical root,
|
|
417
|
+
// while still removing an entity modifier such as `BCM` in `BCM customers`.
|
|
418
|
+
const matchedParsed = parsed.filter((measure) => direct.some((phrase) => measure === phrase || measure.includes(phrase) || phrase.includes(measure)));
|
|
419
|
+
return matchedParsed.length > 0 ? matchedParsed : direct;
|
|
420
|
+
}
|
|
175
421
|
/**
|
|
176
422
|
* Parse only stable analytical roles. This is purposefully narrower than an
|
|
177
423
|
* LLM interpretation: unknown business phrases remain available to the normal
|
|
@@ -180,7 +426,9 @@ function nonRedundantLexicalMeasureTerms(parsedMeasures, question) {
|
|
|
180
426
|
export function buildAnalyticalRequirementSet(input) {
|
|
181
427
|
const question = input.question;
|
|
182
428
|
const lower = question.toLowerCase();
|
|
183
|
-
|
|
429
|
+
// Parser/retrieval evidence may be broad or stale. A requirement set is
|
|
430
|
+
// host authority, so only source-question-grounded refinements may enter it.
|
|
431
|
+
const parsed = currentQuestionGroundedParsedIntent(question, input.parsedIntent);
|
|
184
432
|
const grainMatch = lower.match(/\b(?:by|per|each)\s+(day|week|month|quarter|year)\b|\b(monthly|weekly|quarterly|yearly|daily)\b/i);
|
|
185
433
|
const grainWord = (grainMatch?.[1] ?? grainMatch?.[0]?.replace(/ly\b/i, '') ?? '').toLowerCase();
|
|
186
434
|
const grain = grainWord === 'daily' ? 'day'
|
|
@@ -192,12 +440,18 @@ export function buildAnalyticalRequirementSet(input) {
|
|
|
192
440
|
: undefined;
|
|
193
441
|
const fiscal = lower.match(/\bfy\s?(\d{2,4})\b|\bfiscal\s+year\s+(\d{2,4})\b/i);
|
|
194
442
|
const fiscalPeriod = fiscal ? `FY${fiscal[1] ?? fiscal[2]}`.toUpperCase() : undefined;
|
|
195
|
-
const ranking = lower.match(/\b(top|bottom|highest|lowest)\s*(\d+)?\b/i);
|
|
196
|
-
const
|
|
443
|
+
const ranking = lower.match(/\b(top|bottom|highest|lowest|most|least|expensive|cheapest)\s*(\d+)?\b/i);
|
|
444
|
+
const leadingOrdinalRanking = lower.match(/\b(\d+)\s+(?:most|least|expensive|cheapest)\b/i);
|
|
445
|
+
const requestedDimensions = normalizeAnalyticalDimensionTerms(question, parsed?.dimensions ?? []);
|
|
197
446
|
const entityTerms = uniqueRequirementTerms([
|
|
198
447
|
...((lower.match(/\b(?:account|accounts|customer|customers|client|clients|company|companies)\b/g) ?? [])),
|
|
199
448
|
]).map((term) => term.replace(/s$/, ''));
|
|
200
|
-
|
|
449
|
+
// A ranking result needs a human-readable entity output even when the
|
|
450
|
+
// wording starts with "what" rather than "who" or "which". Treat the
|
|
451
|
+
// entity's display key as a required role for `top accounts` / `top
|
|
452
|
+
// customers`; an entity key, owner field, or sentiment attribute is not a
|
|
453
|
+
// substitute for the result label.
|
|
454
|
+
const entityDisplayTerms = (/\b(?:who|which)\b/i.test(question) || Boolean(ranking && entityTerms.length > 0))
|
|
201
455
|
? uniqueRequirementTerms(entityTerms.map((term) => `${term} name`))
|
|
202
456
|
: [];
|
|
203
457
|
// "this amount" is a deictic reference to a prior result, not a request to
|
|
@@ -206,10 +460,19 @@ export function buildAnalyticalRequirementSet(input) {
|
|
|
206
460
|
// option. Concrete metric words remain typed requirements, including the
|
|
207
461
|
// common revenue/refunds pair used by multi-metric requests.
|
|
208
462
|
const deicticAmount = /\b(?:this|that|the|such)\s+amount\b/i.test(question);
|
|
209
|
-
|
|
463
|
+
// `sales` is a common business synonym for revenue. Do not add the broad
|
|
464
|
+
// revenue root merely because a named metric contains it (`beverage revenue`)
|
|
465
|
+
// or an exact certified block would suddenly look multi-metric.
|
|
466
|
+
const salesIsRevenueAlias = /\bsales\b/i.test(question);
|
|
467
|
+
const parsedMeasures = normalizeAnalyticalMeasureTerms(question, parsed?.measures ?? [])
|
|
468
|
+
.filter((measure) => !/\bsales\s+based\s+on\b/.test(normalizeRequirementTerm(measure)));
|
|
210
469
|
const parsedMeasuresWithLexicalTerms = uniqueRequirementTerms([
|
|
211
470
|
...parsedMeasures,
|
|
212
471
|
...nonRedundantLexicalMeasureTerms(parsedMeasures, question),
|
|
472
|
+
...(salesIsRevenueAlias ? ['revenue'] : []),
|
|
473
|
+
...(/\b(?:most|highest|expensive)\b.*\bproduct\s+price\b|\bproduct\s+price\b.*\b(?:most|highest|expensive)\b/i.test(question)
|
|
474
|
+
? ['product price']
|
|
475
|
+
: []),
|
|
213
476
|
...(!deicticAmount
|
|
214
477
|
&& /\bamount\b/i.test(question)
|
|
215
478
|
&& !parsedMeasures.some((measure) => normalizeRequirementTerm(measure).split(' ').includes('amount'))
|
|
@@ -221,13 +484,31 @@ export function buildAnalyticalRequirementSet(input) {
|
|
|
221
484
|
measures: parsedMeasuresWithLexicalTerms,
|
|
222
485
|
dimensions: requestedDimensions.filter((term) => !isTemporalTerm(term)),
|
|
223
486
|
});
|
|
224
|
-
const
|
|
487
|
+
const explicitRankingMeasures = ranking
|
|
488
|
+
? explicitRankingMeasureTerms(question, typedRequirements.measures)
|
|
489
|
+
: [];
|
|
490
|
+
// A direct ranking clause is the explicit analytical measure. Keep broad
|
|
491
|
+
// parser/retrieval phrases out of the execution tuple so a contextual term
|
|
492
|
+
// cannot become a second ranking metric or force a false clarification.
|
|
493
|
+
const measures = explicitRankingMeasures.length > 0
|
|
494
|
+
? explicitRankingMeasures
|
|
495
|
+
: typedRequirements.measures;
|
|
225
496
|
const dimensions = typedRequirements.dimensions;
|
|
226
497
|
const rankingMetricTerms = ranking ? measures : [];
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
498
|
+
// A context planner supplies its own safety default (`topN: 10`) for bare
|
|
499
|
+
// rankings. It is a useful execution bound, but it is not user intent. Read
|
|
500
|
+
// an explicit count only from the actual question so the cascade/answer
|
|
501
|
+
// receipt can disclose that a bare “top” used DQL's default rather than
|
|
502
|
+
// misleadingly presenting it as a requested limit.
|
|
503
|
+
const wordRankingLimit = lower.match(/\b(?:top|bottom|highest|lowest|most|least|expensive|cheapest)\s+(one|two|three|four|five|six|seven|eight|nine|ten)\b|\b(one|two|three|four|five|six|seven|eight|nine|ten)\s+(?:most|least|expensive|cheapest)\b/i)?.slice(1).find(Boolean)?.toLowerCase();
|
|
504
|
+
const explicitWordLimit = {
|
|
505
|
+
one: 1, two: 2, three: 3, four: 4, five: 5,
|
|
506
|
+
six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
|
|
507
|
+
};
|
|
508
|
+
const explicitLimit = ranking?.[2]
|
|
509
|
+
? Number(ranking[2])
|
|
510
|
+
: leadingOrdinalRanking?.[1] ? Number(leadingOrdinalRanking[1])
|
|
511
|
+
: wordRankingLimit ? explicitWordLimit[wordRankingLimit] : undefined;
|
|
231
512
|
const time = grain || fiscalPeriod
|
|
232
513
|
? {
|
|
233
514
|
role: grain ? 'time_axis' : 'time_filter',
|
|
@@ -243,12 +524,14 @@ export function buildAnalyticalRequirementSet(input) {
|
|
|
243
524
|
entityTerms,
|
|
244
525
|
entityDisplayTerms,
|
|
245
526
|
memberTerms: uniqueRequirementTerms((parsed?.filters ?? []).map((filter) => filter.value)),
|
|
527
|
+
...(explicitOutputTerms(question).length > 0 ? { outputTerms: explicitOutputTerms(question) } : {}),
|
|
528
|
+
...(/\bindividual\b/i.test(question) ? { grain: 'individual' } : {}),
|
|
246
529
|
...(ranking
|
|
247
530
|
? {
|
|
248
531
|
ranking: {
|
|
249
532
|
metricTerms: rankingMetricTerms,
|
|
250
533
|
entityTerms,
|
|
251
|
-
direction: /bottom|lowest/i.test(ranking[1] ?? '') ? 'bottom' : 'top',
|
|
534
|
+
direction: /bottom|lowest|least|cheapest/i.test(ranking[1] ?? '') ? 'bottom' : 'top',
|
|
252
535
|
limit: explicitLimit ?? 10,
|
|
253
536
|
defaultedLimit: explicitLimit === undefined,
|
|
254
537
|
},
|
|
@@ -257,34 +540,98 @@ export function buildAnalyticalRequirementSet(input) {
|
|
|
257
540
|
...(time ? { time } : {}),
|
|
258
541
|
};
|
|
259
542
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
543
|
+
const DECLARED_CANDIDATE_ROLE_ALIASES = [
|
|
544
|
+
['metric', ['metric']],
|
|
545
|
+
['entity_key', ['entity key', 'entity id']],
|
|
546
|
+
['entity_label', ['entity label', 'display key', 'display label']],
|
|
547
|
+
['categorical_dimension', ['categorical dimension', 'category dimension']],
|
|
548
|
+
['time_dimension', ['time dimension', 'date dimension']],
|
|
549
|
+
['member', ['member']],
|
|
550
|
+
['relationship', ['relationship']],
|
|
551
|
+
['context', ['context']],
|
|
552
|
+
];
|
|
553
|
+
/**
|
|
554
|
+
* Only an authored declaration may let an object fill an additional role. A
|
|
555
|
+
* metric's capability lists dimensions, time grains, and relationship paths it
|
|
556
|
+
* *uses*; that does not make the metric itself a display key, time column, or
|
|
557
|
+
* relationship candidate. Compatibility facts are snapshot-authored metadata
|
|
558
|
+
* and therefore the only additive role declaration accepted here.
|
|
559
|
+
*/
|
|
560
|
+
function explicitlyDeclaredCandidateRoles(candidate) {
|
|
561
|
+
const declared = new Set();
|
|
562
|
+
for (const fact of candidate.compatibilityFacts ?? []) {
|
|
563
|
+
const normalized = normalizeRequirementTerm(fact);
|
|
564
|
+
if (!/^(?:(?:candidate|evidence|intrinsic|supported) )?roles?\b/.test(normalized))
|
|
565
|
+
continue;
|
|
566
|
+
const suffix = normalized.replace(/^(?:(?:candidate|evidence|intrinsic|supported) )?roles?\s*/, '');
|
|
567
|
+
for (const [role, aliases] of DECLARED_CANDIDATE_ROLE_ALIASES) {
|
|
568
|
+
if (aliases.some((alias) => new RegExp(`(?:^| )${alias}(?:$| )`).test(suffix)))
|
|
569
|
+
declared.add(role);
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
return declared;
|
|
573
|
+
}
|
|
574
|
+
function intrinsicCandidateIdentity(candidate) {
|
|
575
|
+
return uniqueRequirementTerms([
|
|
263
576
|
candidate.id,
|
|
264
577
|
candidate.qualifiedId,
|
|
265
578
|
candidate.name,
|
|
266
|
-
...(candidate.aliases ?? []),
|
|
267
|
-
...(candidate.dimensions ?? []),
|
|
268
|
-
...(candidate.analyticalCapability?.dimensions ?? []).map((dimension) => dimension.dimensionId),
|
|
269
|
-
...(candidate.analyticalCapability?.timeDimensions ?? []).map((dimension) => dimension.dimensionId),
|
|
270
579
|
]).join(' ');
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Keep account display-key selection separate from common account attributes.
|
|
583
|
+
* Candidate names originate in dbt/semantic identifiers, so underscores and
|
|
584
|
+
* dots must be normalized before testing (`account_sentiment_rating` is just
|
|
585
|
+
* as much an attribute as "Account Sentiment Rating").
|
|
586
|
+
*/
|
|
587
|
+
export function hasEntityAttributeTerm(value) {
|
|
588
|
+
return /\b(?:owner|sentiment|email)\b/i.test(normalizeRequirementTerm(value));
|
|
589
|
+
}
|
|
590
|
+
export function isEntityAttributeCandidate(candidate) {
|
|
591
|
+
return hasEntityAttributeTerm(intrinsicCandidateIdentity(candidate));
|
|
592
|
+
}
|
|
593
|
+
/** Classify the role an already-qualified candidate may fill. */
|
|
594
|
+
export function evidenceCandidateRoles(candidate) {
|
|
595
|
+
const identity = intrinsicCandidateIdentity(candidate);
|
|
271
596
|
const roles = new Set();
|
|
272
|
-
|
|
597
|
+
const physicalColumn = candidate.kind === 'sql_column';
|
|
598
|
+
const metricCandidate = candidate.kind === 'semantic_metric'
|
|
599
|
+
|| candidate.semanticObjectType === 'metric'
|
|
600
|
+
|| candidate.semanticObjectType === 'measure'
|
|
601
|
+
|| /\bmetric\b/.test(identity)
|
|
602
|
+
|| (physicalColumn && /\b(?:revenue|amount|count|rate|bcm|spend|cost|margin|total)\b/.test(identity));
|
|
603
|
+
if (metricCandidate)
|
|
273
604
|
roles.add('metric');
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
605
|
+
// Capability metadata belongs to the metric's execution contract. It must
|
|
606
|
+
// not be treated as the metric object's own entity, display, time, or join
|
|
607
|
+
// identity. Explicit snapshot metadata is the only exception.
|
|
608
|
+
if (!metricCandidate) {
|
|
609
|
+
if (candidate.semanticObjectType === 'entity'
|
|
610
|
+
|| /(?:^| )(?:account|customer|client|company) (?:id|key)\b/.test(identity)
|
|
611
|
+
|| /\bentity\b/.test(identity))
|
|
612
|
+
roles.add('entity_key');
|
|
613
|
+
// Entity identity (for example `semantic:entity:account`) proves an
|
|
614
|
+
// entity key/grain but not the field a person can read in a ranking
|
|
615
|
+
// result. Require an intrinsic display-name declaration instead of
|
|
616
|
+
// allowing every identifier that merely contains "account" or
|
|
617
|
+
// "customer" to fill the entity-label role. Explicit authored role
|
|
618
|
+
// facts below remain the only additive exception.
|
|
619
|
+
if (/\b(?:account|customer|client|company)\b/.test(identity)
|
|
620
|
+
&& /\b(?:name|label|display)\b/.test(identity)
|
|
621
|
+
&& !hasEntityAttributeTerm(identity))
|
|
622
|
+
roles.add('entity_label');
|
|
623
|
+
if (/(?:\bdate\b|\btime\b|\bmonth\b|\bquarter\b|\byear\b|\bfiscal\b|\bperiod\b)/.test(identity)
|
|
624
|
+
|| (candidate.timeGrains?.length ?? 0) > 0)
|
|
625
|
+
roles.add('time_dimension');
|
|
626
|
+
if ((candidate.kind === 'dql_modeling' && (candidate.relationshipEvidence?.length ?? 0) > 0)
|
|
627
|
+
|| /\b(?:relationship|join|bridge)\b/.test(identity))
|
|
628
|
+
roles.add('relationship');
|
|
629
|
+
if (candidate.kind === 'semantic_member' || candidate.semanticObjectType === 'dimension'
|
|
630
|
+
|| (physicalColumn && /\b(?:competitor|region|category|segment|status|type|owner|sentiment|active)\b/.test(identity)))
|
|
631
|
+
roles.add('categorical_dimension');
|
|
632
|
+
}
|
|
633
|
+
for (const role of explicitlyDeclaredCandidateRoles(candidate))
|
|
634
|
+
roles.add(role);
|
|
288
635
|
if (candidate.kind === 'sql_column' || candidate.kind === 'dbt_model' || candidate.kind === 'sql_table')
|
|
289
636
|
roles.add('context');
|
|
290
637
|
if (roles.size === 0)
|
|
@@ -303,9 +650,63 @@ function candidateMatchesTerms(candidate, terms, options = {}) {
|
|
|
303
650
|
]).join(' ');
|
|
304
651
|
if (terms.some((term) => identity.includes(term) || term.includes(identity)))
|
|
305
652
|
return true;
|
|
653
|
+
if (options.categoricalDimension === true
|
|
654
|
+
&& terms.some((term) => categoricalDimensionTermsMatch(term, identity)))
|
|
655
|
+
return true;
|
|
306
656
|
return options.categoricalDimension === true
|
|
307
657
|
&& candidateMatchesCategoricalDimensionRequirement(candidate, terms);
|
|
308
658
|
}
|
|
659
|
+
/**
|
|
660
|
+
* A metric's attached dimensions, source model, and relationship path are
|
|
661
|
+
* execution context. They are deliberately useful for binding a complete
|
|
662
|
+
* plan, but must never make one metric match the name of another. In
|
|
663
|
+
* particular, `bcm_run_rate` from an `account_revenue` model is not the
|
|
664
|
+
* `revenue` ranking measure merely because its source-model identity contains
|
|
665
|
+
* that word.
|
|
666
|
+
*/
|
|
667
|
+
function metricCandidateIdentityTerms(candidate) {
|
|
668
|
+
const terminalMetricIdentity = (value) => {
|
|
669
|
+
if (!value)
|
|
670
|
+
return undefined;
|
|
671
|
+
const namespaceLeaf = value.split(':').filter(Boolean).at(-1) ?? value;
|
|
672
|
+
const metricLeaf = namespaceLeaf.split(/[./]/).filter(Boolean).at(-1) ?? namespaceLeaf;
|
|
673
|
+
return normalizeRequirementTerm(metricLeaf);
|
|
674
|
+
};
|
|
675
|
+
return uniqueRequirementTerms([
|
|
676
|
+
// Metadata cards sometimes use their source-qualified identifier as the
|
|
677
|
+
// display label (for example `account_revenue.bcm_run_rate`). Treat that
|
|
678
|
+
// exactly like an ID: its terminal metric leaf is intrinsic identity and
|
|
679
|
+
// its model prefix is execution context. A human label such as `Total
|
|
680
|
+
// Revenue` has no namespace separator and is retained intact.
|
|
681
|
+
terminalMetricIdentity(candidate.name),
|
|
682
|
+
...(candidate.aliases ?? []).map(terminalMetricIdentity),
|
|
683
|
+
terminalMetricIdentity(candidate.id),
|
|
684
|
+
terminalMetricIdentity(candidate.qualifiedId),
|
|
685
|
+
]);
|
|
686
|
+
}
|
|
687
|
+
function metricCandidateMatchesTerms(candidate, terms) {
|
|
688
|
+
const identities = metricCandidateIdentityTerms(candidate);
|
|
689
|
+
return terms.some((term) => {
|
|
690
|
+
const normalizedTerm = normalizeRequirementTerm(term);
|
|
691
|
+
if (!normalizedTerm)
|
|
692
|
+
return false;
|
|
693
|
+
return identities.some((identity) => identity === normalizedTerm
|
|
694
|
+
|| identity.endsWith(` ${normalizedTerm}`)
|
|
695
|
+
|| normalizedTerm.endsWith(` ${identity}`));
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* A direct ranking measure is authoritative for metric admission. Other
|
|
700
|
+
* retrieved metrics remain visible in the lifecycle receipt, but they cannot
|
|
701
|
+
* become a second metric choice merely because they are correlated with the
|
|
702
|
+
* entity phrase in the question.
|
|
703
|
+
*/
|
|
704
|
+
export function candidateConflictsWithExplicitRankingMeasure(candidate, requirements) {
|
|
705
|
+
const metricTerms = requirements.ranking?.metricTerms ?? [];
|
|
706
|
+
return metricTerms.length > 0
|
|
707
|
+
&& evidenceCandidateRoles(candidate).includes('metric')
|
|
708
|
+
&& !metricCandidateMatchesTerms(candidate, metricTerms);
|
|
709
|
+
}
|
|
309
710
|
/**
|
|
310
711
|
* A categorical dimension may satisfy a requested business role only through
|
|
311
712
|
* its own snapshot-authored declaration. In particular, `location_name` is
|
|
@@ -315,6 +716,14 @@ function candidateMatchesTerms(candidate, terms, options = {}) {
|
|
|
315
716
|
* broad lexical geography expansion while retaining role-balanced recall.
|
|
316
717
|
*/
|
|
317
718
|
export function candidateMatchesCategoricalDimensionRequirement(candidate, terms) {
|
|
719
|
+
const extension = candidate.sameSnapshotRoleExtension;
|
|
720
|
+
if (extension?.role === 'categorical_dimension'
|
|
721
|
+
&& (extension.basis === 'sole_metricflow_grouping_dimension'
|
|
722
|
+
|| extension.basis === 'exact_metricflow_grouping_dimension')) {
|
|
723
|
+
const requested = new Set(terms.map(normalizeRequirementTerm).filter(Boolean));
|
|
724
|
+
if (requested.has(normalizeRequirementTerm(extension.requestedTerm)))
|
|
725
|
+
return true;
|
|
726
|
+
}
|
|
318
727
|
const facts = new Set((candidate.compatibilityFacts ?? [])
|
|
319
728
|
.map(normalizeRequirementTerm)
|
|
320
729
|
.filter(Boolean));
|
|
@@ -348,7 +757,9 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
348
757
|
|| left.id.localeCompare(right.id));
|
|
349
758
|
const selected = [];
|
|
350
759
|
const add = (candidate) => {
|
|
351
|
-
if (candidate
|
|
760
|
+
if (!candidate || candidateConflictsWithExplicitRankingMeasure(candidate, input.requirements))
|
|
761
|
+
return;
|
|
762
|
+
if (selected.length < max && !selected.some((item) => item.id === candidate.id))
|
|
352
763
|
selected.push(candidate);
|
|
353
764
|
};
|
|
354
765
|
const servesRequestedRole = (candidate) => {
|
|
@@ -368,9 +779,10 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
368
779
|
return true;
|
|
369
780
|
if (roles.includes('time_dimension') && Boolean(input.requirements.time))
|
|
370
781
|
return true;
|
|
782
|
+
const categoricalTerms = categoricalDimensionRequirementTerms(input.requirements);
|
|
371
783
|
if (roles.includes('categorical_dimension')
|
|
372
|
-
&&
|
|
373
|
-
&& candidateMatchesTerms(candidate,
|
|
784
|
+
&& categoricalTerms.length > 0
|
|
785
|
+
&& candidateMatchesTerms(candidate, categoricalTerms, { categoricalDimension: true }))
|
|
374
786
|
return true;
|
|
375
787
|
if (roles.includes('relationship')
|
|
376
788
|
&& (input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0))
|
|
@@ -389,7 +801,7 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
389
801
|
['metric', input.requirements.ranking?.metricTerms.length ? input.requirements.ranking.metricTerms : input.requirements.measures],
|
|
390
802
|
['entity_label', [...input.requirements.entityTerms, ...input.requirements.entityDisplayTerms]],
|
|
391
803
|
['time_dimension', input.requirements.time ? [input.requirements.time.grain ?? 'time'] : []],
|
|
392
|
-
['categorical_dimension', input.requirements
|
|
804
|
+
['categorical_dimension', categoricalDimensionRequirementTerms(input.requirements)],
|
|
393
805
|
['relationship', input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0 ? ['relationship'] : []],
|
|
394
806
|
];
|
|
395
807
|
for (const [role, terms] of required) {
|
|
@@ -411,7 +823,7 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
411
823
|
// role nor a useful categorical reservation unless the user explicitly
|
|
412
824
|
// named that attribute. This runs during the pre-cap pin pass so noisy
|
|
413
825
|
// same-kind cards cannot enter through the categorical role.
|
|
414
|
-
const explicitlyRequestsAttribute =
|
|
826
|
+
const explicitlyRequestsAttribute = hasEntityAttributeTerm([
|
|
415
827
|
...input.requirements.dimensions,
|
|
416
828
|
...input.requirements.entityTerms,
|
|
417
829
|
...input.requirements.entityDisplayTerms,
|
|
@@ -424,7 +836,7 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
424
836
|
if (role === 'categorical_dimension'
|
|
425
837
|
&& hasRequestedEntityLabel
|
|
426
838
|
&& !explicitlyRequestsAttribute
|
|
427
|
-
&&
|
|
839
|
+
&& isEntityAttributeCandidate(candidate))
|
|
428
840
|
continue;
|
|
429
841
|
// For entity labels, role is more important than a lexical owner/email
|
|
430
842
|
// hit. For all other roles, prefer an identity matching the requested
|
|
@@ -446,13 +858,13 @@ export function classifyProviderFailure(input) {
|
|
|
446
858
|
const text = `${input.code ?? ''} ${input.message ?? ''}`.toLowerCase();
|
|
447
859
|
const cause = /cancel/.test(text) ? 'cancelled'
|
|
448
860
|
: /dispatch.?budget|provider_dispatch_budget/.test(text) ? 'dispatch_budget'
|
|
449
|
-
: /deadline.?insufficient|admission|soft.?target/.test(text) ? 'admission_denied'
|
|
861
|
+
: /deadline.?insufficient|admission|soft.?target|provider_result_rows_(?:blocked|limit_exceeded)/.test(text) ? 'admission_denied'
|
|
450
862
|
: /run.?deadline|time limit/.test(text) ? 'run_deadline'
|
|
451
863
|
: /timeout|timed out/.test(text) ? 'provider_timeout'
|
|
452
864
|
: /401|403|api key|unauthori[sz]ed|auth(?:entication)?/.test(text) ? 'authentication'
|
|
453
865
|
: /model(?:[ _-]+|\s+).*not[ _-]?found|unknown model|model_not_found|404/.test(text) ? 'model_not_found'
|
|
454
866
|
: /429|rate[ _-]?limit|too many requests/.test(text) ? 'rate_limited'
|
|
455
|
-
:
|
|
867
|
+
: /\b5\d{2}\b|gateway/.test(text) ? 'gateway'
|
|
456
868
|
: /econn|network|fetch failed|not reachable|connection refused/.test(text) ? 'network'
|
|
457
869
|
: 'unknown';
|
|
458
870
|
const retryable = cause === 'rate_limited' || cause === 'gateway' || cause === 'network' || cause === 'provider_timeout';
|
|
@@ -461,7 +873,7 @@ export function classifyProviderFailure(input) {
|
|
|
461
873
|
: cause === 'cancelled' ? 'none'
|
|
462
874
|
: 'inspect_run';
|
|
463
875
|
const httpStatusClass = /\b(?:401|403|404|429)\b/.test(text) ? '4xx'
|
|
464
|
-
: /\
|
|
876
|
+
: /\b5\d{2}\b/.test(text) ? '5xx'
|
|
465
877
|
: undefined;
|
|
466
878
|
return {
|
|
467
879
|
version: 1,
|
|
@@ -476,6 +888,29 @@ export function classifyProviderFailure(input) {
|
|
|
476
888
|
};
|
|
477
889
|
}
|
|
478
890
|
export function buildAnalyticalCascadeDecision(input) {
|
|
891
|
+
// A frozen tier is the end of the authoritative cascade. Later tiers are
|
|
892
|
+
// neither evaluated nor eligible as a fallback, even when the selected
|
|
893
|
+
// compiler, adapter, or execution target subsequently fails. Retaining
|
|
894
|
+
// pre-built later attempts in the receipt makes a truthful post-freeze
|
|
895
|
+
// failure look like a silent downgrade and invalidates portable replay.
|
|
896
|
+
// Normalize at the shared construction boundary so every router path and
|
|
897
|
+
// every emitted trace receives the same immutable attempt prefix.
|
|
898
|
+
const attempts = [];
|
|
899
|
+
for (const inputAttempt of input.attempts) {
|
|
900
|
+
const attempt = {
|
|
901
|
+
...inputAttempt,
|
|
902
|
+
version: 1,
|
|
903
|
+
candidateIds: [...new Set(inputAttempt.candidateIds)].slice(0, 32),
|
|
904
|
+
// Decision-level `planFrozen` is the server-owned source of truth. A
|
|
905
|
+
// legacy caller may have stamped it only on the decision; keep the
|
|
906
|
+
// selected attempt coherent before enforcing the immutable prefix.
|
|
907
|
+
planFrozen: inputAttempt.planFrozen
|
|
908
|
+
|| (input.planFrozen === true && input.selectedTier === inputAttempt.tier),
|
|
909
|
+
};
|
|
910
|
+
attempts.push(attempt);
|
|
911
|
+
if (attempt.planFrozen)
|
|
912
|
+
break;
|
|
913
|
+
}
|
|
479
914
|
return {
|
|
480
915
|
version: 1,
|
|
481
916
|
...input,
|
|
@@ -484,7 +919,15 @@ export function buildAnalyticalCascadeDecision(input) {
|
|
|
484
919
|
version: 1,
|
|
485
920
|
candidateIds: [...new Set(coverage.candidateIds)].slice(0, 32),
|
|
486
921
|
})),
|
|
487
|
-
attempts
|
|
922
|
+
attempts,
|
|
923
|
+
...(input.terminalGap ? {
|
|
924
|
+
terminalGap: {
|
|
925
|
+
version: 1,
|
|
926
|
+
code: 'MISSING_RELATIONSHIP',
|
|
927
|
+
requirement: 'certified_relationship_or_allocation_proof',
|
|
928
|
+
witnessCandidateIds: [...new Set(input.terminalGap.witnessCandidateIds)].sort().slice(0, 32),
|
|
929
|
+
},
|
|
930
|
+
} : {}),
|
|
488
931
|
};
|
|
489
932
|
}
|
|
490
933
|
/**
|