@duckcodeailabs/dql-agent 1.14.2 → 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 +239 -4
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +2924 -85
- 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-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 +591 -11
- package/dist/agent-run-store.js.map +1 -1
- package/dist/agentic/analyst-loop.d.ts +9 -1
- package/dist/agentic/analyst-loop.d.ts.map +1 -1
- package/dist/agentic/analyst-loop.js +204 -4
- 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/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/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/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-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 +42 -1
- package/dist/analytical-frame.d.ts.map +1 -1
- package/dist/analytical-frame.js +490 -26
- package/dist/analytical-frame.js.map +1 -1
- package/dist/analytical-orchestration.d.ts +1259 -9
- package/dist/analytical-orchestration.d.ts.map +1 -1
- package/dist/analytical-orchestration.js +970 -84
- package/dist/analytical-orchestration.js.map +1 -1
- package/dist/answer-loop.d.ts +230 -10
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +1182 -85
- 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/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 +523 -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 +1089 -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 +380 -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/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.d.ts +37 -1
- package/dist/generated-analytical-proposal.d.ts.map +1 -1
- package/dist/generated-analytical-proposal.js +246 -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 +21 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -7
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +87 -1
- package/dist/intent-controller.d.ts.map +1 -1
- package/dist/intent-controller.js +2 -2
- 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 +207 -6
- package/dist/meaning-resolution.d.ts.map +1 -1
- package/dist/meaning-resolution.js +439 -53
- package/dist/meaning-resolution.js.map +1 -1
- package/dist/metadata/analysis-planner.d.ts.map +1 -1
- package/dist/metadata/analysis-planner.js +78 -7
- 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 +29 -2
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/meaning-evidence.d.ts +28 -2
- package/dist/metadata/meaning-evidence.d.ts.map +1 -1
- package/dist/metadata/meaning-evidence.js +373 -16
- 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 +28 -11
- package/dist/provider-egress.d.ts.map +1 -1
- package/dist/provider-egress.js +72 -22
- 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 +201 -37
- 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.d.ts +2 -2
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +213 -43
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/types.d.ts +131 -1
- 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/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 +18 -4
- package/dist/resolved-analytical-plan.d.ts.map +1 -1
- package/dist/resolved-analytical-plan.js +953 -50
- package/dist/resolved-analytical-plan.js.map +1 -1
- package/dist/router.d.ts +118 -2
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +2481 -232
- 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
|
@@ -12,7 +12,265 @@
|
|
|
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
|
+
}
|
|
66
|
+
/** Build the immutable host request tuple before a meaning model can respond. */
|
|
67
|
+
export function buildAnalyticalRequirementSeedV1(input) {
|
|
68
|
+
// Retrieval/parser output is intentionally broad: it may contain useful
|
|
69
|
+
// context from a prior turn, vector hit, or search expansion. It is not an
|
|
70
|
+
// authority for a new free-text request. Keep only refinements that the
|
|
71
|
+
// source question itself demonstrates before they can contribute to the
|
|
72
|
+
// frozen host tuple. Structured clarification selections are merged by the
|
|
73
|
+
// router into `requirements` before this function is called.
|
|
74
|
+
const parsed = currentQuestionGroundedParsedIntent(input.question, input.parsedIntent);
|
|
75
|
+
const baseRequirements = input.requirements ?? buildAnalyticalRequirementSet({
|
|
76
|
+
question: input.question,
|
|
77
|
+
parsedIntent: parsed,
|
|
78
|
+
});
|
|
79
|
+
const requirements = withAnalyticalPriorResultMemberBinding(baseRequirements, input.priorResultMemberBinding ?? baseRequirements.priorResultMemberBinding);
|
|
80
|
+
// Order and limit are lexical requirements, not parser defaults. In
|
|
81
|
+
// particular, a prior ranking must not turn a complete new question into a
|
|
82
|
+
// top-N query just because retrieval retained an old `limit` or `order`.
|
|
83
|
+
const order = requirements.ranking
|
|
84
|
+
? requirements.ranking.direction === 'bottom' ? 'asc' : 'desc'
|
|
85
|
+
: undefined;
|
|
86
|
+
const limit = requirements.ranking?.limit;
|
|
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
|
+
}
|
|
95
|
+
if (requirements.time?.fiscalPeriod && input.fiscalCalendar?.fiscalPeriodFieldId
|
|
96
|
+
&& !filters.some((filter) => filter.field === input.fiscalCalendar.fiscalPeriodFieldId)) {
|
|
97
|
+
filters.push({ field: input.fiscalCalendar.fiscalPeriodFieldId, value: requirements.time.fiscalPeriod });
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
version: 1,
|
|
101
|
+
sourceQuestion: input.question,
|
|
102
|
+
requirements,
|
|
103
|
+
queryIntent: {
|
|
104
|
+
measures: [...requirements.measures],
|
|
105
|
+
// A ranking's entity display key is an execution requirement, not a
|
|
106
|
+
// prompt nicety. Keep it in the host-owned query tuple, but do not put
|
|
107
|
+
// the broad entity noun (for example `customer`) in the categorical
|
|
108
|
+
// dimension lane. A metric can legitimately expose customer type and
|
|
109
|
+
// customer order number as groupings; neither is interchangeable with
|
|
110
|
+
// the requested customer display/rank key. The frame resolves the
|
|
111
|
+
// display term against the selected metric's native display/rank role.
|
|
112
|
+
dimensions: [...new Set([
|
|
113
|
+
...categoricalDimensionRequirementTerms(requirements),
|
|
114
|
+
...requirements.entityDisplayTerms,
|
|
115
|
+
])],
|
|
116
|
+
filters,
|
|
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 } : {}),
|
|
124
|
+
...(requirements.time?.grain
|
|
125
|
+
? { timeGrain: requirements.time.grain }
|
|
126
|
+
: parsed?.timeGrain ? { timeGrain: parsed.timeGrain } : {}),
|
|
127
|
+
...(order ? { order } : {}),
|
|
128
|
+
...(limit !== undefined ? { limit } : {}),
|
|
129
|
+
// A calendar/date role is declared snapshot metadata, never an ID
|
|
130
|
+
// copied from retrieval/parser evidence.
|
|
131
|
+
...(input.fiscalCalendar?.id ? { fiscalCalendarId: input.fiscalCalendar.id } : {}),
|
|
132
|
+
...(input.fiscalCalendar?.dateRoleId ? { fiscalDateRoleId: input.fiscalCalendar.dateRoleId } : {}),
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Return only parser refinements whose business words occur in the current
|
|
138
|
+
* source question. This deliberately does not try to recover previous turn
|
|
139
|
+
* context: continuation is represented by a server-issued structured choice
|
|
140
|
+
* and merged separately by the host. The helper is exported for regression
|
|
141
|
+
* tests and for router paths that construct a requirement set before a seed.
|
|
142
|
+
*/
|
|
143
|
+
export function currentQuestionGroundedParsedIntent(question, parsedIntent) {
|
|
144
|
+
if (!parsedIntent)
|
|
145
|
+
return undefined;
|
|
146
|
+
// These are intentionally tiny, product-wide vocabulary aliases rather
|
|
147
|
+
// than semantic guessing. They let a retrieval parser retain the same
|
|
148
|
+
// current-turn business phrase (`drink revenue` for `beverage revenue`, or
|
|
149
|
+
// `sales` for `revenue`) while still rejecting an unrelated prior-turn
|
|
150
|
+
// phrase such as `rollover balance`. Do not add customer/model-specific
|
|
151
|
+
// synonyms here: those require a qualified selected candidate and an
|
|
152
|
+
// override receipt at the meaning boundary.
|
|
153
|
+
const canonicalGroundingToken = (term) => {
|
|
154
|
+
if (term === 'sale' || term === 'sales')
|
|
155
|
+
return 'revenue';
|
|
156
|
+
if (term === 'drink' || term === 'drinks')
|
|
157
|
+
return 'beverage';
|
|
158
|
+
return term;
|
|
159
|
+
};
|
|
160
|
+
const questionTerms = new Set(normalizeRequirementTerm(question)
|
|
161
|
+
.split(' ')
|
|
162
|
+
.filter((term) => term.length > 1)
|
|
163
|
+
.map(canonicalGroundingToken));
|
|
164
|
+
const groundedTerm = (value) => {
|
|
165
|
+
const terms = normalizeRequirementTerm(value ?? '')
|
|
166
|
+
.split(' ')
|
|
167
|
+
.filter((term) => term.length > 1 && !/^(?:the|a|an|by|for|with|and|or|of|to|in|on|at)$/.test(term))
|
|
168
|
+
.map(canonicalGroundingToken);
|
|
169
|
+
return terms.length > 0 && terms.every((term) => {
|
|
170
|
+
if (questionTerms.has(term))
|
|
171
|
+
return true;
|
|
172
|
+
// Preserve only a trivial singular/plural normalization. Anything more
|
|
173
|
+
// permissive would let a stale retrieved phrase become a new request.
|
|
174
|
+
return term.endsWith('s')
|
|
175
|
+
? questionTerms.has(term.slice(0, -1))
|
|
176
|
+
: questionTerms.has(`${term}s`);
|
|
177
|
+
});
|
|
178
|
+
};
|
|
179
|
+
const timeRangeGrounded = (value) => {
|
|
180
|
+
const normalized = normalizeRequirementTerm(value ?? '');
|
|
181
|
+
if (!normalized)
|
|
182
|
+
return false;
|
|
183
|
+
if (groundedTerm(normalized))
|
|
184
|
+
return true;
|
|
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));
|
|
197
|
+
};
|
|
198
|
+
const measures = (parsedIntent.measures ?? []).filter(groundedTerm);
|
|
199
|
+
const dimensions = (parsedIntent.dimensions ?? []).filter(groundedTerm);
|
|
200
|
+
// A parser may contribute the column that a reader's explicitly named value
|
|
201
|
+
// belongs to, but the value itself must appear in this request. This drops
|
|
202
|
+
// stale rollover-balance/member filters while retaining a current named
|
|
203
|
+
// member that the host can bind to a qualified field.
|
|
204
|
+
const filters = (parsedIntent.filters ?? []).filter((filter) => groundedTerm(filter.value));
|
|
205
|
+
const timeRange = timeRangeGrounded(parsedIntent.timeRange)
|
|
206
|
+
? parsedIntent.timeRange
|
|
207
|
+
: undefined;
|
|
208
|
+
return {
|
|
209
|
+
...(measures.length > 0 ? { measures } : {}),
|
|
210
|
+
...(dimensions.length > 0 ? { dimensions } : {}),
|
|
211
|
+
...(filters.length > 0 ? { filters } : {}),
|
|
212
|
+
...(timeRange ? { timeRange } : {}),
|
|
213
|
+
// Grain/ranking/limit are derived deterministically from the source
|
|
214
|
+
// question below. Never promote parser values by themselves.
|
|
215
|
+
};
|
|
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
|
+
}
|
|
16
274
|
/**
|
|
17
275
|
* Small, explainable intent vocabulary used before the physical plan exists.
|
|
18
276
|
* It is deliberately not a second semantic matcher: candidate identity still
|
|
@@ -54,6 +312,42 @@ function uniqueRequirementTerms(values) {
|
|
|
54
312
|
.map(normalizeRequirementTerm)
|
|
55
313
|
.filter(Boolean))];
|
|
56
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* Entity/display terms are represented in the historical `dimensions` seed
|
|
317
|
+
* so ranking plans can retain their requested grain. They must not consume
|
|
318
|
+
* the separate categorical-dimension admission lane. For example, in "top
|
|
319
|
+
* customers by product category", `customer` is the entity/rank role while
|
|
320
|
+
* `product category` is the required categorical grouping role.
|
|
321
|
+
*/
|
|
322
|
+
export function categoricalDimensionRequirementTerms(requirements) {
|
|
323
|
+
const entityTerms = new Set(uniqueRequirementTerms([
|
|
324
|
+
...requirements.entityTerms,
|
|
325
|
+
...requirements.entityDisplayTerms,
|
|
326
|
+
]));
|
|
327
|
+
return uniqueRequirementTerms(requirements.dimensions)
|
|
328
|
+
.filter((term) => !entityTerms.has(term));
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* A small, typed vocabulary bridge for categorical field identities. It is
|
|
332
|
+
* intentionally not a general synonym engine: only the field-kind suffix is
|
|
333
|
+
* canonicalized, while the scoped business noun must still match. Thus
|
|
334
|
+
* `product category` can bind the snapshot-declared `product_type`, whereas
|
|
335
|
+
* `customer_type` cannot satisfy it. A bare `category` remains potentially
|
|
336
|
+
* ambiguous when more than one qualified `*_type` field exists.
|
|
337
|
+
*/
|
|
338
|
+
export function categoricalDimensionTermsMatch(left, right) {
|
|
339
|
+
const normalizedLeft = normalizeRequirementTerm(left);
|
|
340
|
+
const normalizedRight = normalizeRequirementTerm(right);
|
|
341
|
+
if (!normalizedLeft || !normalizedRight)
|
|
342
|
+
return false;
|
|
343
|
+
const phraseMatch = (a, b) => a === b || a.endsWith(` ${b}`) || b.endsWith(` ${a}`);
|
|
344
|
+
if (phraseMatch(normalizedLeft, normalizedRight))
|
|
345
|
+
return true;
|
|
346
|
+
const canonicalizeKind = (value) => value
|
|
347
|
+
.replace(/\bcategories\b/g, 'type')
|
|
348
|
+
.replace(/\bcategory\b/g, 'type');
|
|
349
|
+
return phraseMatch(canonicalizeKind(normalizedLeft), canonicalizeKind(normalizedRight));
|
|
350
|
+
}
|
|
57
351
|
function isTemporalTerm(term) {
|
|
58
352
|
return /^(?:date|day|week|month|quarter|year|fy\d{2,4}|fiscal year|fiscal quarter)$/.test(term);
|
|
59
353
|
}
|
|
@@ -112,6 +406,43 @@ function normalizedTypedAggregationRequirements(input) {
|
|
|
112
406
|
]);
|
|
113
407
|
return { measures, dimensions };
|
|
114
408
|
}
|
|
409
|
+
/**
|
|
410
|
+
* Keep grammatical wrappers out of the physical tuple. In particular, a
|
|
411
|
+
* parser can return `sales based on the region` as a dimension for a simple
|
|
412
|
+
* revenue-by-region request. That is neither a business dimension nor an
|
|
413
|
+
* object DQL may report as absent.
|
|
414
|
+
*/
|
|
415
|
+
function normalizeAnalyticalDimensionTerms(question, values) {
|
|
416
|
+
const hasRegion = /\b(?:by|based\s+on(?:\s+the)?|across|per)\s+(?:the\s+)?region\b/i.test(question);
|
|
417
|
+
const hasProductCategory = /\bproduct\s+categor(?:y|ies)\b/i.test(question);
|
|
418
|
+
// A planner can surface the noun from a projected field as a grouping
|
|
419
|
+
// dimension (for example `product` from “with product ID and product
|
|
420
|
+
// price”). An output is not a `by product` group. Keep the noun only when
|
|
421
|
+
// the reader actually supplied a grouping construction; otherwise the
|
|
422
|
+
// host-owned row-level/output tuple would acquire a fake dimension and
|
|
423
|
+
// make an otherwise single-table exploratory plan ambiguous.
|
|
424
|
+
const outputRoots = new Set(explicitOutputTerms(question)
|
|
425
|
+
.map((term) => term.replace(/\s+(?:id|name|price)$/i, '').trim())
|
|
426
|
+
.filter(Boolean));
|
|
427
|
+
const isExplicitGroupingRoot = (term) => {
|
|
428
|
+
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
429
|
+
return new RegExp(`\\b(?:by|per|across|for\\s+each)\\s+(?:the\\s+)?${escaped}(?:s)?\\b`, 'i').test(question);
|
|
430
|
+
};
|
|
431
|
+
const normalized = uniqueRequirementTerms([...values])
|
|
432
|
+
.filter((value) => !/\b(?:revenue|sales)\b.*\b(?:based\s+on|by)\b.*\bregion\b/.test(value))
|
|
433
|
+
.filter((value) => !(hasProductCategory && /^(?:product|category)$/.test(value)))
|
|
434
|
+
.filter((value) => !outputRoots.has(value) || isExplicitGroupingRoot(value));
|
|
435
|
+
return uniqueRequirementTerms([
|
|
436
|
+
...normalized,
|
|
437
|
+
...(hasRegion ? ['region'] : []),
|
|
438
|
+
...(hasProductCategory ? ['product category'] : []),
|
|
439
|
+
]);
|
|
440
|
+
}
|
|
441
|
+
function explicitOutputTerms(question) {
|
|
442
|
+
const terms = [...question.matchAll(/\b(?:order|product|customer|account)\s+(?:id|name|price)\b/gi)]
|
|
443
|
+
.map((match) => match[0] ?? '');
|
|
444
|
+
return uniqueRequirementTerms(terms);
|
|
445
|
+
}
|
|
115
446
|
/**
|
|
116
447
|
* Normalize grammatical aggregation wrappers before they become a plan
|
|
117
448
|
* requirement. Retrieval/parser output is allowed to retain useful search
|
|
@@ -119,20 +450,26 @@ function normalizedTypedAggregationRequirements(input) {
|
|
|
119
450
|
* or "for each customer" as separate physical measures. The grouping entity
|
|
120
451
|
* is represented by the dimension/entity roles instead.
|
|
121
452
|
*
|
|
122
|
-
* `order count for each customer` is the common prose form for
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
* 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.
|
|
126
460
|
*/
|
|
127
461
|
export function normalizeAnalyticalMeasureTerms(question, values, options = {}) {
|
|
128
|
-
|
|
129
|
-
|
|
462
|
+
// A parser often singularizes the business alias `sales` into `sale`. That
|
|
463
|
+
// is not a second measure beside revenue: it is the same current-question
|
|
464
|
+
// request. Canonicalize only that standalone vocabulary alias here; named
|
|
465
|
+
// measures such as `sales_tax` or `sales_pipeline` keep their identity.
|
|
466
|
+
const canonicalMeasureAlias = (value) => {
|
|
467
|
+
const normalized = normalizeRequirementTerm(value);
|
|
468
|
+
return normalized === 'sale' || normalized === 'sales' ? 'revenue' : value;
|
|
469
|
+
};
|
|
130
470
|
const terms = values
|
|
131
|
-
.
|
|
132
|
-
.filter((value) => !(
|
|
133
|
-
if (groupedOrderCount && !terms.some((value) => normalizeRequirementTerm(value) === 'count')) {
|
|
134
|
-
terms.push('count');
|
|
135
|
-
}
|
|
471
|
+
.map(canonicalMeasureAlias)
|
|
472
|
+
.filter((value) => !isStructuralMeasurePhrase(value));
|
|
136
473
|
// An inherited measure can already be a stable semantic/dbt identity. Keep
|
|
137
474
|
// that identity intact for the planner/meaning handoff; matching and display
|
|
138
475
|
// have their own normalizers. Rewriting `total_consumption_units` to prose
|
|
@@ -156,22 +493,111 @@ export function normalizeAnalyticalMeasureTerms(question, values, options = {})
|
|
|
156
493
|
* phrase that already owns it: adding both `beverage revenue` and `revenue`
|
|
157
494
|
* turns one requested metric into two and incorrectly rejects a block whose
|
|
158
495
|
* own declared output is `beverage_revenue`. The same holds for `order count`
|
|
159
|
-
* and its generic `count` root;
|
|
160
|
-
*
|
|
496
|
+
* and its generic `count` root; the authored compound is retained and owns
|
|
497
|
+
* that generic aggregation vocabulary for this question.
|
|
161
498
|
*/
|
|
162
499
|
function nonRedundantLexicalMeasureTerms(parsedMeasures, question) {
|
|
163
|
-
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)))
|
|
164
508
|
.filter((term) => new RegExp(`\\b${term.replace(' ', '\\s+')}\\b`, 'i').test(question));
|
|
165
509
|
return lexical.filter((term) => {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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)
|
|
171
518
|
.split(' ')
|
|
172
|
-
.
|
|
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
|
+
});
|
|
173
528
|
});
|
|
174
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)));
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* A ranking question can contain a business qualifier immediately before the
|
|
566
|
+
* ranked entity (for example, "BCM customers") as well as the measure that
|
|
567
|
+
* actually orders the result ("highest revenue"). The retrieval parser keeps
|
|
568
|
+
* both phrases because both are useful for recall, but they are not equivalent
|
|
569
|
+
* plan requirements. Prefer a direct ranking clause over the broad parser
|
|
570
|
+
* hint before any candidate is admitted as a metric.
|
|
571
|
+
*
|
|
572
|
+
* This deliberately remains narrow. It only disambiguates when the parser
|
|
573
|
+
* supplied competing measures and the user also wrote an explicit comparator;
|
|
574
|
+
* parser-absent and already-unambiguous ranking requests retain their existing
|
|
575
|
+
* normal meaning-resolution path.
|
|
576
|
+
*/
|
|
577
|
+
function explicitRankingMeasureTerms(question, parsedMeasures) {
|
|
578
|
+
const parsed = uniqueRequirementTerms([...parsedMeasures]);
|
|
579
|
+
if (parsed.length < 2)
|
|
580
|
+
return [];
|
|
581
|
+
const phrases = [];
|
|
582
|
+
const endOfMeasure = String.raw `(?=\s+(?:across|among|for|per|in|where|during|over|with|that|which|who|and|or)\b|[?.!,;]|$)`;
|
|
583
|
+
for (const pattern of [
|
|
584
|
+
new RegExp(String.raw `\b(?:highest|lowest|most|least)\s+(?:the\s+)?([a-z][a-z0-9_. -]{0,80}?)${endOfMeasure}`, 'gi'),
|
|
585
|
+
]) {
|
|
586
|
+
for (const match of question.matchAll(pattern)) {
|
|
587
|
+
const phrase = normalizeRequirementTerm(match[1] ?? '');
|
|
588
|
+
if (phrase && !isTemporalTerm(phrase))
|
|
589
|
+
phrases.push(phrase);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const direct = uniqueRequirementTerms(phrases);
|
|
593
|
+
if (direct.length === 0)
|
|
594
|
+
return [];
|
|
595
|
+
// Preserve the parser's more stable authored phrase when it is the same
|
|
596
|
+
// measure. This avoids replacing `net revenue` with a looser lexical root,
|
|
597
|
+
// while still removing an entity modifier such as `BCM` in `BCM customers`.
|
|
598
|
+
const matchedParsed = parsed.filter((measure) => direct.some((phrase) => measure === phrase || measure.includes(phrase) || phrase.includes(measure)));
|
|
599
|
+
return matchedParsed.length > 0 ? matchedParsed : direct;
|
|
600
|
+
}
|
|
175
601
|
/**
|
|
176
602
|
* Parse only stable analytical roles. This is purposefully narrower than an
|
|
177
603
|
* LLM interpretation: unknown business phrases remain available to the normal
|
|
@@ -180,7 +606,9 @@ function nonRedundantLexicalMeasureTerms(parsedMeasures, question) {
|
|
|
180
606
|
export function buildAnalyticalRequirementSet(input) {
|
|
181
607
|
const question = input.question;
|
|
182
608
|
const lower = question.toLowerCase();
|
|
183
|
-
|
|
609
|
+
// Parser/retrieval evidence may be broad or stale. A requirement set is
|
|
610
|
+
// host authority, so only source-question-grounded refinements may enter it.
|
|
611
|
+
const parsed = currentQuestionGroundedParsedIntent(question, input.parsedIntent);
|
|
184
612
|
const grainMatch = lower.match(/\b(?:by|per|each)\s+(day|week|month|quarter|year)\b|\b(monthly|weekly|quarterly|yearly|daily)\b/i);
|
|
185
613
|
const grainWord = (grainMatch?.[1] ?? grainMatch?.[0]?.replace(/ly\b/i, '') ?? '').toLowerCase();
|
|
186
614
|
const grain = grainWord === 'daily' ? 'day'
|
|
@@ -192,12 +620,18 @@ export function buildAnalyticalRequirementSet(input) {
|
|
|
192
620
|
: undefined;
|
|
193
621
|
const fiscal = lower.match(/\bfy\s?(\d{2,4})\b|\bfiscal\s+year\s+(\d{2,4})\b/i);
|
|
194
622
|
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
|
|
623
|
+
const ranking = lower.match(/\b(top|bottom|highest|lowest|most|least|expensive|cheapest)\s*(\d+)?\b/i);
|
|
624
|
+
const leadingOrdinalRanking = lower.match(/\b(\d+)\s+(?:most|least|expensive|cheapest)\b/i);
|
|
625
|
+
const requestedDimensions = normalizeAnalyticalDimensionTerms(question, parsed?.dimensions ?? []);
|
|
197
626
|
const entityTerms = uniqueRequirementTerms([
|
|
198
627
|
...((lower.match(/\b(?:account|accounts|customer|customers|client|clients|company|companies)\b/g) ?? [])),
|
|
199
628
|
]).map((term) => term.replace(/s$/, ''));
|
|
200
|
-
|
|
629
|
+
// A ranking result needs a human-readable entity output even when the
|
|
630
|
+
// wording starts with "what" rather than "who" or "which". Treat the
|
|
631
|
+
// entity's display key as a required role for `top accounts` / `top
|
|
632
|
+
// customers`; an entity key, owner field, or sentiment attribute is not a
|
|
633
|
+
// substitute for the result label.
|
|
634
|
+
const entityDisplayTerms = (/\b(?:who|which)\b/i.test(question) || Boolean(ranking && entityTerms.length > 0))
|
|
201
635
|
? uniqueRequirementTerms(entityTerms.map((term) => `${term} name`))
|
|
202
636
|
: [];
|
|
203
637
|
// "this amount" is a deictic reference to a prior result, not a request to
|
|
@@ -206,32 +640,70 @@ export function buildAnalyticalRequirementSet(input) {
|
|
|
206
640
|
// option. Concrete metric words remain typed requirements, including the
|
|
207
641
|
// common revenue/refunds pair used by multi-metric requests.
|
|
208
642
|
const deicticAmount = /\b(?:this|that|the|such)\s+amount\b/i.test(question);
|
|
209
|
-
|
|
210
|
-
|
|
643
|
+
// `sales` is a common business synonym for revenue. Do not add the broad
|
|
644
|
+
// revenue root merely because a named metric contains it (`beverage revenue`)
|
|
645
|
+
// or an exact certified block would suddenly look multi-metric.
|
|
646
|
+
const salesIsRevenueAlias = /\bsales\b/i.test(question);
|
|
647
|
+
const parsedMeasures = normalizeAnalyticalMeasureTerms(question, parsed?.measures ?? [])
|
|
648
|
+
.filter((measure) => !/\bsales\s+based\s+on\b/.test(normalizeRequirementTerm(measure)));
|
|
649
|
+
const parsedMeasuresWithLexicalTerms = preferExplicitQuestionCompoundMeasures(uniqueRequirementTerms([
|
|
211
650
|
...parsedMeasures,
|
|
212
651
|
...nonRedundantLexicalMeasureTerms(parsedMeasures, question),
|
|
652
|
+
...(salesIsRevenueAlias ? ['revenue'] : []),
|
|
653
|
+
...(/\b(?:most|highest|expensive)\b.*\bproduct\s+price\b|\bproduct\s+price\b.*\b(?:most|highest|expensive)\b/i.test(question)
|
|
654
|
+
? ['product price']
|
|
655
|
+
: []),
|
|
213
656
|
...(!deicticAmount
|
|
214
657
|
&& /\bamount\b/i.test(question)
|
|
215
658
|
&& !parsedMeasures.some((measure) => normalizeRequirementTerm(measure).split(' ').includes('amount'))
|
|
216
659
|
? ['amount']
|
|
217
660
|
: []),
|
|
218
|
-
]);
|
|
661
|
+
]), question);
|
|
219
662
|
const typedRequirements = normalizedTypedAggregationRequirements({
|
|
220
663
|
question,
|
|
221
664
|
measures: parsedMeasuresWithLexicalTerms,
|
|
222
665
|
dimensions: requestedDimensions.filter((term) => !isTemporalTerm(term)),
|
|
223
666
|
});
|
|
224
|
-
const
|
|
225
|
-
|
|
667
|
+
const explicitRankingMeasures = ranking
|
|
668
|
+
? explicitRankingMeasureTerms(question, typedRequirements.measures)
|
|
669
|
+
: [];
|
|
670
|
+
// A direct ranking clause is the explicit analytical measure. Keep broad
|
|
671
|
+
// parser/retrieval phrases out of the execution tuple so a contextual term
|
|
672
|
+
// cannot become a second ranking metric or force a false clarification.
|
|
673
|
+
const measures = explicitRankingMeasures.length > 0
|
|
674
|
+
? explicitRankingMeasures
|
|
675
|
+
: typedRequirements.measures;
|
|
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)));
|
|
226
683
|
const rankingMetricTerms = ranking ? measures : [];
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
684
|
+
// A context planner supplies its own safety default (`topN: 10`) for bare
|
|
685
|
+
// rankings. It is a useful execution bound, but it is not user intent. Read
|
|
686
|
+
// an explicit count only from the actual question so the cascade/answer
|
|
687
|
+
// receipt can disclose that a bare “top” used DQL's default rather than
|
|
688
|
+
// misleadingly presenting it as a requested limit.
|
|
689
|
+
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();
|
|
690
|
+
const explicitWordLimit = {
|
|
691
|
+
one: 1, two: 2, three: 3, four: 4, five: 5,
|
|
692
|
+
six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
|
|
693
|
+
};
|
|
694
|
+
const explicitLimit = ranking?.[2]
|
|
695
|
+
? Number(ranking[2])
|
|
696
|
+
: leadingOrdinalRanking?.[1] ? Number(leadingOrdinalRanking[1])
|
|
697
|
+
: wordRankingLimit ? explicitWordLimit[wordRankingLimit] : undefined;
|
|
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
|
|
232
703
|
? {
|
|
233
704
|
role: grain ? 'time_axis' : 'time_filter',
|
|
234
705
|
...(grain ? { grain: grain } : {}),
|
|
706
|
+
...(window ? { window } : {}),
|
|
235
707
|
...(fiscalPeriod ? { fiscalPeriod } : {}),
|
|
236
708
|
requiresDeclaredFiscalCalendar: Boolean(fiscalPeriod),
|
|
237
709
|
}
|
|
@@ -242,13 +714,22 @@ export function buildAnalyticalRequirementSet(input) {
|
|
|
242
714
|
dimensions,
|
|
243
715
|
entityTerms,
|
|
244
716
|
entityDisplayTerms,
|
|
245
|
-
|
|
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
|
+
]),
|
|
725
|
+
...(explicitOutputTerms(question).length > 0 ? { outputTerms: explicitOutputTerms(question) } : {}),
|
|
726
|
+
...(/\bindividual\b/i.test(question) ? { grain: 'individual' } : {}),
|
|
246
727
|
...(ranking
|
|
247
728
|
? {
|
|
248
729
|
ranking: {
|
|
249
730
|
metricTerms: rankingMetricTerms,
|
|
250
731
|
entityTerms,
|
|
251
|
-
direction: /bottom|lowest/i.test(ranking[1] ?? '') ? 'bottom' : 'top',
|
|
732
|
+
direction: /bottom|lowest|least|cheapest/i.test(ranking[1] ?? '') ? 'bottom' : 'top',
|
|
252
733
|
limit: explicitLimit ?? 10,
|
|
253
734
|
defaultedLimit: explicitLimit === undefined,
|
|
254
735
|
},
|
|
@@ -257,34 +738,163 @@ export function buildAnalyticalRequirementSet(input) {
|
|
|
257
738
|
...(time ? { time } : {}),
|
|
258
739
|
};
|
|
259
740
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
741
|
+
const DECLARED_CANDIDATE_ROLE_ALIASES = [
|
|
742
|
+
['metric', ['metric']],
|
|
743
|
+
['entity_key', ['entity key', 'entity id']],
|
|
744
|
+
['entity_label', ['entity label', 'display key', 'display label']],
|
|
745
|
+
['categorical_dimension', ['categorical dimension', 'category dimension']],
|
|
746
|
+
['time_dimension', ['time dimension', 'date dimension']],
|
|
747
|
+
['member', ['member']],
|
|
748
|
+
['relationship', ['relationship']],
|
|
749
|
+
['context', ['context']],
|
|
750
|
+
];
|
|
751
|
+
/**
|
|
752
|
+
* Only an authored declaration may let an object fill an additional role. A
|
|
753
|
+
* metric's capability lists dimensions, time grains, and relationship paths it
|
|
754
|
+
* *uses*; that does not make the metric itself a display key, time column, or
|
|
755
|
+
* relationship candidate. Compatibility facts are snapshot-authored metadata
|
|
756
|
+
* and therefore the only additive role declaration accepted here.
|
|
757
|
+
*/
|
|
758
|
+
function explicitlyDeclaredCandidateRoles(candidate) {
|
|
759
|
+
const declared = new Set();
|
|
760
|
+
for (const fact of candidate.compatibilityFacts ?? []) {
|
|
761
|
+
const normalized = normalizeRequirementTerm(fact);
|
|
762
|
+
if (!/^(?:(?:candidate|evidence|intrinsic|supported) )?roles?\b/.test(normalized))
|
|
763
|
+
continue;
|
|
764
|
+
const suffix = normalized.replace(/^(?:(?:candidate|evidence|intrinsic|supported) )?roles?\s*/, '');
|
|
765
|
+
for (const [role, aliases] of DECLARED_CANDIDATE_ROLE_ALIASES) {
|
|
766
|
+
if (aliases.some((alias) => new RegExp(`(?:^| )${alias}(?:$| )`).test(suffix)))
|
|
767
|
+
declared.add(role);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return declared;
|
|
771
|
+
}
|
|
772
|
+
function intrinsicCandidateIdentity(candidate) {
|
|
773
|
+
return uniqueRequirementTerms([
|
|
263
774
|
candidate.id,
|
|
264
775
|
candidate.qualifiedId,
|
|
265
776
|
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
777
|
]).join(' ');
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Keep account display-key selection separate from common account attributes.
|
|
781
|
+
* Candidate names originate in dbt/semantic identifiers, so underscores and
|
|
782
|
+
* dots must be normalized before testing (`account_sentiment_rating` is just
|
|
783
|
+
* as much an attribute as "Account Sentiment Rating").
|
|
784
|
+
*/
|
|
785
|
+
export function hasEntityAttributeTerm(value) {
|
|
786
|
+
return /\b(?:owner|sentiment|email)\b/i.test(normalizeRequirementTerm(value));
|
|
787
|
+
}
|
|
788
|
+
export function isEntityAttributeCandidate(candidate) {
|
|
789
|
+
return hasEntityAttributeTerm(intrinsicCandidateIdentity(candidate));
|
|
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
|
+
}
|
|
817
|
+
/** Classify the role an already-qualified candidate may fill. */
|
|
818
|
+
export function evidenceCandidateRoles(candidate) {
|
|
819
|
+
const identity = intrinsicCandidateIdentity(candidate);
|
|
271
820
|
const roles = new Set();
|
|
272
|
-
|
|
821
|
+
const physicalColumn = candidate.kind === 'sql_column';
|
|
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'
|
|
829
|
+
|| candidate.semanticObjectType === 'metric'
|
|
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;
|
|
846
|
+
if (metricCandidate)
|
|
273
847
|
roles.add('metric');
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
848
|
+
// Capability metadata belongs to the metric's execution contract. It must
|
|
849
|
+
// not be treated as the metric object's own entity, display, time, or join
|
|
850
|
+
// identity. Explicit snapshot metadata is the only exception.
|
|
851
|
+
if (!metricCandidate) {
|
|
852
|
+
if (candidate.semanticObjectType === 'entity'
|
|
853
|
+
|| /(?:^| )(?:account|customer|client|company) (?:id|key)\b/.test(identity)
|
|
854
|
+
|| /\bentity\b/.test(identity))
|
|
855
|
+
roles.add('entity_key');
|
|
856
|
+
// Entity identity (for example `semantic:entity:account`) proves an
|
|
857
|
+
// entity key/grain but not the field a person can read in a ranking
|
|
858
|
+
// result. Require an intrinsic display-name declaration instead of
|
|
859
|
+
// allowing every identifier that merely contains "account" or
|
|
860
|
+
// "customer" to fill the entity-label role. Explicit authored role
|
|
861
|
+
// facts below remain the only additive exception.
|
|
862
|
+
if (/\b(?:account|customer|client|company)\b/.test(identity)
|
|
863
|
+
&& /\b(?:name|label|display)\b/.test(identity)
|
|
864
|
+
&& !hasEntityAttributeTerm(identity))
|
|
865
|
+
roles.add('entity_label');
|
|
866
|
+
if (temporalCandidate)
|
|
867
|
+
roles.add('time_dimension');
|
|
868
|
+
if ((candidate.kind === 'dql_modeling' && (candidate.relationshipEvidence?.length ?? 0) > 0)
|
|
869
|
+
|| /\b(?:relationship|join|bridge)\b/.test(identity))
|
|
870
|
+
roles.add('relationship');
|
|
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)))) {
|
|
884
|
+
roles.add('categorical_dimension');
|
|
885
|
+
}
|
|
886
|
+
if (candidate.kind === 'semantic_member' && candidate.semanticObjectType === 'member')
|
|
887
|
+
roles.add('member');
|
|
888
|
+
}
|
|
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;
|
|
896
|
+
roles.add(role);
|
|
897
|
+
}
|
|
288
898
|
if (candidate.kind === 'sql_column' || candidate.kind === 'dbt_model' || candidate.kind === 'sql_table')
|
|
289
899
|
roles.add('context');
|
|
290
900
|
if (roles.size === 0)
|
|
@@ -303,9 +913,63 @@ function candidateMatchesTerms(candidate, terms, options = {}) {
|
|
|
303
913
|
]).join(' ');
|
|
304
914
|
if (terms.some((term) => identity.includes(term) || term.includes(identity)))
|
|
305
915
|
return true;
|
|
916
|
+
if (options.categoricalDimension === true
|
|
917
|
+
&& terms.some((term) => categoricalDimensionTermsMatch(term, identity)))
|
|
918
|
+
return true;
|
|
306
919
|
return options.categoricalDimension === true
|
|
307
920
|
&& candidateMatchesCategoricalDimensionRequirement(candidate, terms);
|
|
308
921
|
}
|
|
922
|
+
/**
|
|
923
|
+
* A metric's attached dimensions, source model, and relationship path are
|
|
924
|
+
* execution context. They are deliberately useful for binding a complete
|
|
925
|
+
* plan, but must never make one metric match the name of another. In
|
|
926
|
+
* particular, `bcm_run_rate` from an `account_revenue` model is not the
|
|
927
|
+
* `revenue` ranking measure merely because its source-model identity contains
|
|
928
|
+
* that word.
|
|
929
|
+
*/
|
|
930
|
+
function metricCandidateIdentityTerms(candidate) {
|
|
931
|
+
const terminalMetricIdentity = (value) => {
|
|
932
|
+
if (!value)
|
|
933
|
+
return undefined;
|
|
934
|
+
const namespaceLeaf = value.split(':').filter(Boolean).at(-1) ?? value;
|
|
935
|
+
const metricLeaf = namespaceLeaf.split(/[./]/).filter(Boolean).at(-1) ?? namespaceLeaf;
|
|
936
|
+
return normalizeRequirementTerm(metricLeaf);
|
|
937
|
+
};
|
|
938
|
+
return uniqueRequirementTerms([
|
|
939
|
+
// Metadata cards sometimes use their source-qualified identifier as the
|
|
940
|
+
// display label (for example `account_revenue.bcm_run_rate`). Treat that
|
|
941
|
+
// exactly like an ID: its terminal metric leaf is intrinsic identity and
|
|
942
|
+
// its model prefix is execution context. A human label such as `Total
|
|
943
|
+
// Revenue` has no namespace separator and is retained intact.
|
|
944
|
+
terminalMetricIdentity(candidate.name),
|
|
945
|
+
...(candidate.aliases ?? []).map(terminalMetricIdentity),
|
|
946
|
+
terminalMetricIdentity(candidate.id),
|
|
947
|
+
terminalMetricIdentity(candidate.qualifiedId),
|
|
948
|
+
]);
|
|
949
|
+
}
|
|
950
|
+
function metricCandidateMatchesTerms(candidate, terms) {
|
|
951
|
+
const identities = metricCandidateIdentityTerms(candidate);
|
|
952
|
+
return terms.some((term) => {
|
|
953
|
+
const normalizedTerm = normalizeRequirementTerm(term);
|
|
954
|
+
if (!normalizedTerm)
|
|
955
|
+
return false;
|
|
956
|
+
return identities.some((identity) => identity === normalizedTerm
|
|
957
|
+
|| identity.endsWith(` ${normalizedTerm}`)
|
|
958
|
+
|| normalizedTerm.endsWith(` ${identity}`));
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
/**
|
|
962
|
+
* A direct ranking measure is authoritative for metric admission. Other
|
|
963
|
+
* retrieved metrics remain visible in the lifecycle receipt, but they cannot
|
|
964
|
+
* become a second metric choice merely because they are correlated with the
|
|
965
|
+
* entity phrase in the question.
|
|
966
|
+
*/
|
|
967
|
+
export function candidateConflictsWithExplicitRankingMeasure(candidate, requirements) {
|
|
968
|
+
const metricTerms = requirements.ranking?.metricTerms ?? [];
|
|
969
|
+
return metricTerms.length > 0
|
|
970
|
+
&& evidenceCandidateRoles(candidate).includes('metric')
|
|
971
|
+
&& !metricCandidateMatchesTerms(candidate, metricTerms);
|
|
972
|
+
}
|
|
309
973
|
/**
|
|
310
974
|
* A categorical dimension may satisfy a requested business role only through
|
|
311
975
|
* its own snapshot-authored declaration. In particular, `location_name` is
|
|
@@ -315,6 +979,14 @@ function candidateMatchesTerms(candidate, terms, options = {}) {
|
|
|
315
979
|
* broad lexical geography expansion while retaining role-balanced recall.
|
|
316
980
|
*/
|
|
317
981
|
export function candidateMatchesCategoricalDimensionRequirement(candidate, terms) {
|
|
982
|
+
const extension = candidate.sameSnapshotRoleExtension;
|
|
983
|
+
if (extension?.role === 'categorical_dimension'
|
|
984
|
+
&& (extension.basis === 'sole_metricflow_grouping_dimension'
|
|
985
|
+
|| extension.basis === 'exact_metricflow_grouping_dimension')) {
|
|
986
|
+
const requested = new Set(terms.map(normalizeRequirementTerm).filter(Boolean));
|
|
987
|
+
if (requested.has(normalizeRequirementTerm(extension.requestedTerm)))
|
|
988
|
+
return true;
|
|
989
|
+
}
|
|
318
990
|
const facts = new Set((candidate.compatibilityFacts ?? [])
|
|
319
991
|
.map(normalizeRequirementTerm)
|
|
320
992
|
.filter(Boolean));
|
|
@@ -339,7 +1011,12 @@ export function candidateMatchesCategoricalDimensionRequirement(candidate, terms
|
|
|
339
1011
|
* to two candidates before relevance fills remaining cards.
|
|
340
1012
|
*/
|
|
341
1013
|
export function selectRoleBalancedMeaningCandidates(input) {
|
|
342
|
-
|
|
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)));
|
|
343
1020
|
const ranked = [...new Map(input.candidates
|
|
344
1021
|
.filter((candidate) => candidate.id.trim() && candidate.compatibility !== 'incompatible')
|
|
345
1022
|
.map((candidate) => [candidate.id, candidate])).values()]
|
|
@@ -348,9 +1025,12 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
348
1025
|
|| left.id.localeCompare(right.id));
|
|
349
1026
|
const selected = [];
|
|
350
1027
|
const add = (candidate) => {
|
|
351
|
-
if (candidate
|
|
1028
|
+
if (!candidate || candidateConflictsWithExplicitRankingMeasure(candidate, input.requirements))
|
|
1029
|
+
return;
|
|
1030
|
+
if (selected.length < max && !selected.some((item) => item.id === candidate.id))
|
|
352
1031
|
selected.push(candidate);
|
|
353
1032
|
};
|
|
1033
|
+
const categoricalTerms = categoricalDimensionRequirementTerms(input.requirements);
|
|
354
1034
|
const servesRequestedRole = (candidate) => {
|
|
355
1035
|
const roles = evidenceCandidateRoles(candidate);
|
|
356
1036
|
const metricTerms = input.requirements.ranking?.metricTerms.length
|
|
@@ -361,6 +1041,8 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
361
1041
|
// An entity term such as "account" is deliberately insufficient for an
|
|
362
1042
|
// attribute (Account Owner Email) to displace the requested display key.
|
|
363
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;
|
|
364
1046
|
if (roles.includes('entity_label') && candidateMatchesTerms(candidate, [
|
|
365
1047
|
...input.requirements.entityTerms,
|
|
366
1048
|
...input.requirements.entityDisplayTerms,
|
|
@@ -369,8 +1051,14 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
369
1051
|
if (roles.includes('time_dimension') && Boolean(input.requirements.time))
|
|
370
1052
|
return true;
|
|
371
1053
|
if (roles.includes('categorical_dimension')
|
|
372
|
-
&&
|
|
373
|
-
&& candidateMatchesTerms(candidate,
|
|
1054
|
+
&& categoricalTerms.length > 0
|
|
1055
|
+
&& candidateMatchesTerms(candidate, categoricalTerms, { categoricalDimension: true }))
|
|
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))
|
|
374
1062
|
return true;
|
|
375
1063
|
if (roles.includes('relationship')
|
|
376
1064
|
&& (input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0))
|
|
@@ -386,13 +1074,19 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
386
1074
|
add(candidate);
|
|
387
1075
|
}
|
|
388
1076
|
const required = [
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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 },
|
|
394
1088
|
];
|
|
395
|
-
for (const
|
|
1089
|
+
for (const { role, terms, limit, categorical } of required) {
|
|
396
1090
|
// No requested categorical dimension means that high-scoring arbitrary
|
|
397
1091
|
// members are noise, not a role reservation. This is the subtle path that
|
|
398
1092
|
// used to admit Account Owner and Sentiment immediately after Account Name.
|
|
@@ -400,10 +1094,13 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
400
1094
|
continue;
|
|
401
1095
|
let admitted = 0;
|
|
402
1096
|
for (const candidate of ranked) {
|
|
403
|
-
if (admitted >=
|
|
1097
|
+
if (admitted >= limit || selected.length >= max)
|
|
404
1098
|
break;
|
|
405
1099
|
const roles = evidenceCandidateRoles(candidate);
|
|
406
|
-
|
|
1100
|
+
const safePhysicalMember = role === 'member'
|
|
1101
|
+
&& roles.includes('categorical_dimension')
|
|
1102
|
+
&& candidateHasSafeValueForMemberTerms(candidate, terms);
|
|
1103
|
+
if (!roles.includes(role) && !safePhysicalMember)
|
|
407
1104
|
continue;
|
|
408
1105
|
// "top accounts" needs the account display key, not any field whose
|
|
409
1106
|
// label happens to contain account. Once a display candidate is
|
|
@@ -411,7 +1108,7 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
411
1108
|
// role nor a useful categorical reservation unless the user explicitly
|
|
412
1109
|
// named that attribute. This runs during the pre-cap pin pass so noisy
|
|
413
1110
|
// same-kind cards cannot enter through the categorical role.
|
|
414
|
-
const explicitlyRequestsAttribute =
|
|
1111
|
+
const explicitlyRequestsAttribute = hasEntityAttributeTerm([
|
|
415
1112
|
...input.requirements.dimensions,
|
|
416
1113
|
...input.requirements.entityTerms,
|
|
417
1114
|
...input.requirements.entityDisplayTerms,
|
|
@@ -424,12 +1121,13 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
424
1121
|
if (role === 'categorical_dimension'
|
|
425
1122
|
&& hasRequestedEntityLabel
|
|
426
1123
|
&& !explicitlyRequestsAttribute
|
|
427
|
-
&&
|
|
1124
|
+
&& isEntityAttributeCandidate(candidate))
|
|
428
1125
|
continue;
|
|
429
1126
|
// For entity labels, role is more important than a lexical owner/email
|
|
430
1127
|
// hit. For all other roles, prefer an identity matching the requested
|
|
431
1128
|
// business term but retain a role candidate when the request is terse.
|
|
432
|
-
if (terms.length > 0 && !
|
|
1129
|
+
if (terms.length > 0 && !safePhysicalMember
|
|
1130
|
+
&& !candidateMatchesTerms(candidate, terms, { categoricalDimension: categorical === true })
|
|
433
1131
|
&& role !== 'time_dimension' && role !== 'relationship' && role !== 'entity_label')
|
|
434
1132
|
continue;
|
|
435
1133
|
add(candidate);
|
|
@@ -442,17 +1140,38 @@ export function selectRoleBalancedMeaningCandidates(input) {
|
|
|
442
1140
|
}
|
|
443
1141
|
return selected;
|
|
444
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
|
+
}
|
|
445
1164
|
export function classifyProviderFailure(input) {
|
|
446
1165
|
const text = `${input.code ?? ''} ${input.message ?? ''}`.toLowerCase();
|
|
447
1166
|
const cause = /cancel/.test(text) ? 'cancelled'
|
|
448
1167
|
: /dispatch.?budget|provider_dispatch_budget/.test(text) ? 'dispatch_budget'
|
|
449
|
-
: /deadline.?insufficient|admission|soft.?target/.test(text) ? 'admission_denied'
|
|
1168
|
+
: /deadline.?insufficient|admission|soft.?target|provider_result_rows_(?:blocked|limit_exceeded)/.test(text) ? 'admission_denied'
|
|
450
1169
|
: /run.?deadline|time limit/.test(text) ? 'run_deadline'
|
|
451
1170
|
: /timeout|timed out/.test(text) ? 'provider_timeout'
|
|
452
1171
|
: /401|403|api key|unauthori[sz]ed|auth(?:entication)?/.test(text) ? 'authentication'
|
|
453
1172
|
: /model(?:[ _-]+|\s+).*not[ _-]?found|unknown model|model_not_found|404/.test(text) ? 'model_not_found'
|
|
454
1173
|
: /429|rate[ _-]?limit|too many requests/.test(text) ? 'rate_limited'
|
|
455
|
-
:
|
|
1174
|
+
: /\b5\d{2}\b|gateway/.test(text) ? 'gateway'
|
|
456
1175
|
: /econn|network|fetch failed|not reachable|connection refused/.test(text) ? 'network'
|
|
457
1176
|
: 'unknown';
|
|
458
1177
|
const retryable = cause === 'rate_limited' || cause === 'gateway' || cause === 'network' || cause === 'provider_timeout';
|
|
@@ -461,7 +1180,7 @@ export function classifyProviderFailure(input) {
|
|
|
461
1180
|
: cause === 'cancelled' ? 'none'
|
|
462
1181
|
: 'inspect_run';
|
|
463
1182
|
const httpStatusClass = /\b(?:401|403|404|429)\b/.test(text) ? '4xx'
|
|
464
|
-
: /\
|
|
1183
|
+
: /\b5\d{2}\b/.test(text) ? '5xx'
|
|
465
1184
|
: undefined;
|
|
466
1185
|
return {
|
|
467
1186
|
version: 1,
|
|
@@ -476,6 +1195,29 @@ export function classifyProviderFailure(input) {
|
|
|
476
1195
|
};
|
|
477
1196
|
}
|
|
478
1197
|
export function buildAnalyticalCascadeDecision(input) {
|
|
1198
|
+
// A frozen tier is the end of the authoritative cascade. Later tiers are
|
|
1199
|
+
// neither evaluated nor eligible as a fallback, even when the selected
|
|
1200
|
+
// compiler, adapter, or execution target subsequently fails. Retaining
|
|
1201
|
+
// pre-built later attempts in the receipt makes a truthful post-freeze
|
|
1202
|
+
// failure look like a silent downgrade and invalidates portable replay.
|
|
1203
|
+
// Normalize at the shared construction boundary so every router path and
|
|
1204
|
+
// every emitted trace receives the same immutable attempt prefix.
|
|
1205
|
+
const attempts = [];
|
|
1206
|
+
for (const inputAttempt of input.attempts) {
|
|
1207
|
+
const attempt = {
|
|
1208
|
+
...inputAttempt,
|
|
1209
|
+
version: 1,
|
|
1210
|
+
candidateIds: [...new Set(inputAttempt.candidateIds)].slice(0, 32),
|
|
1211
|
+
// Decision-level `planFrozen` is the server-owned source of truth. A
|
|
1212
|
+
// legacy caller may have stamped it only on the decision; keep the
|
|
1213
|
+
// selected attempt coherent before enforcing the immutable prefix.
|
|
1214
|
+
planFrozen: inputAttempt.planFrozen
|
|
1215
|
+
|| (input.planFrozen === true && input.selectedTier === inputAttempt.tier),
|
|
1216
|
+
};
|
|
1217
|
+
attempts.push(attempt);
|
|
1218
|
+
if (attempt.planFrozen)
|
|
1219
|
+
break;
|
|
1220
|
+
}
|
|
479
1221
|
return {
|
|
480
1222
|
version: 1,
|
|
481
1223
|
...input,
|
|
@@ -484,7 +1226,15 @@ export function buildAnalyticalCascadeDecision(input) {
|
|
|
484
1226
|
version: 1,
|
|
485
1227
|
candidateIds: [...new Set(coverage.candidateIds)].slice(0, 32),
|
|
486
1228
|
})),
|
|
487
|
-
attempts
|
|
1229
|
+
attempts,
|
|
1230
|
+
...(input.terminalGap ? {
|
|
1231
|
+
terminalGap: {
|
|
1232
|
+
version: 1,
|
|
1233
|
+
code: 'MISSING_RELATIONSHIP',
|
|
1234
|
+
requirement: 'certified_relationship_or_allocation_proof',
|
|
1235
|
+
witnessCandidateIds: [...new Set(input.terminalGap.witnessCandidateIds)].sort().slice(0, 32),
|
|
1236
|
+
},
|
|
1237
|
+
} : {}),
|
|
488
1238
|
};
|
|
489
1239
|
}
|
|
490
1240
|
/**
|
|
@@ -516,23 +1266,76 @@ export function splitAnalyticalTasks(question) {
|
|
|
516
1266
|
const parts = (asked.length > 0 ? asked : raw)
|
|
517
1267
|
.map((part) => part.replace(leadingJunk, '').replace(trailingJunk, '').trim())
|
|
518
1268
|
.filter(Boolean);
|
|
519
|
-
|
|
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;
|
|
520
1301
|
}
|
|
521
1302
|
export function buildAnalyticalTaskGraph(input) {
|
|
522
|
-
|
|
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;
|
|
523
1312
|
// A research turn may later create bounded evidence branches, but that is a
|
|
524
1313
|
// research planner's job. Splitting at ingress loses the surrounding story
|
|
525
1314
|
// before it has an opportunity to reason about it.
|
|
526
|
-
const
|
|
527
|
-
|
|
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);
|
|
528
1326
|
const candidateIds = [...new Set((input.candidateIds ?? []).filter((id) => id.trim()))];
|
|
529
1327
|
const metrics = [...new Set((input.metrics ?? []).filter((metric) => metric.trim()))];
|
|
530
1328
|
const dimensions = [...new Set((input.dimensions ?? []).filter((dimension) => dimension.trim()))];
|
|
531
1329
|
const filters = input.filters ?? [];
|
|
532
1330
|
const inheritedBindings = input.inheritedBindings ?? [];
|
|
533
1331
|
const unboundTasks = clauses.map((clause, index) => {
|
|
534
|
-
const
|
|
535
|
-
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';
|
|
536
1339
|
const taskKind = research
|
|
537
1340
|
? 'research_branch'
|
|
538
1341
|
: kind === 'lookup'
|
|
@@ -567,7 +1370,7 @@ export function buildAnalyticalTaskGraph(input) {
|
|
|
567
1370
|
return {
|
|
568
1371
|
kind: rootKind === 'research' ? 'research' : tasks.length > 1 ? 'compound' : rootKind,
|
|
569
1372
|
tasks,
|
|
570
|
-
partial
|
|
1373
|
+
partial,
|
|
571
1374
|
};
|
|
572
1375
|
}
|
|
573
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;
|
|
@@ -957,6 +1760,89 @@ export async function retrieveContextLanes(lanes, limit = 32, maxConcurrent = 4)
|
|
|
957
1760
|
}
|
|
958
1761
|
return fused;
|
|
959
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
|
+
}
|
|
960
1846
|
export function capResearchBranches(branches, max = 6) {
|
|
961
1847
|
return branches.slice(0, Math.max(1, Math.min(6, Math.trunc(max))));
|
|
962
1848
|
}
|