@duckcodeailabs/dql-agent 1.14.1 → 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 +71 -2
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +1048 -31
- 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 +12 -0
- 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 +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 +211 -28
- 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/synthesis.d.ts +4 -0
- package/dist/agentic/research/synthesis.d.ts.map +1 -1
- package/dist/agentic/research/synthesis.js +23 -8
- package/dist/agentic/research/synthesis.js.map +1 -1
- package/dist/agentic/research-agent.d.ts.map +1 -1
- package/dist/agentic/research-agent.js +13 -3
- 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 +233 -2
- 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 +332 -17
- package/dist/analytical-frame.js.map +1 -1
- package/dist/analytical-orchestration.d.ts +613 -0
- package/dist/analytical-orchestration.d.ts.map +1 -1
- package/dist/analytical-orchestration.js +1007 -0
- package/dist/analytical-orchestration.js.map +1 -1
- package/dist/answer-loop.d.ts +110 -6
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +1180 -125
- package/dist/answer-loop.js.map +1 -1
- package/dist/answer-shape.d.ts +32 -1
- package/dist/answer-shape.d.ts.map +1 -1
- package/dist/answer-shape.js +49 -3
- 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 +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/conversation/snapshot.d.ts +20 -0
- package/dist/conversation/snapshot.d.ts.map +1 -1
- package/dist/conversation/snapshot.js +30 -1
- package/dist/conversation/snapshot.js.map +1 -1
- package/dist/fixtures/ask-ai-office-shaped.d.ts +153 -0
- package/dist/fixtures/ask-ai-office-shaped.d.ts.map +1 -0
- package/dist/fixtures/ask-ai-office-shaped.js +94 -0
- package/dist/fixtures/ask-ai-office-shaped.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 +11 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +38 -0
- package/dist/intent-controller.d.ts.map +1 -1
- package/dist/intent-controller.js +31 -1
- package/dist/intent-controller.js.map +1 -1
- package/dist/meaning-resolution.d.ts +141 -2
- package/dist/meaning-resolution.d.ts.map +1 -1
- package/dist/meaning-resolution.js +444 -30
- package/dist/meaning-resolution.js.map +1 -1
- package/dist/metadata/analysis-planner.d.ts.map +1 -1
- package/dist/metadata/analysis-planner.js +144 -7
- package/dist/metadata/analysis-planner.js.map +1 -1
- package/dist/metadata/block-fit.d.ts +18 -0
- package/dist/metadata/block-fit.d.ts.map +1 -1
- package/dist/metadata/block-fit.js +250 -38
- package/dist/metadata/block-fit.js.map +1 -1
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +58 -12
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/meaning-evidence.d.ts +19 -3
- package/dist/metadata/meaning-evidence.d.ts.map +1 -1
- package/dist/metadata/meaning-evidence.js +518 -54
- 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/research-loop.d.ts.map +1 -1
- package/dist/research-loop.js +23 -1
- package/dist/research-loop.js.map +1 -1
- package/dist/resolved-analytical-plan.d.ts +22 -1
- package/dist/resolved-analytical-plan.d.ts.map +1 -1
- package/dist/resolved-analytical-plan.js +773 -49
- package/dist/resolved-analytical-plan.js.map +1 -1
- package/dist/router.d.ts +14 -4
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +3668 -425
- 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/dist/semantic-bridge/member-select.d.ts.map +1 -1
- package/dist/semantic-bridge/member-select.js +39 -21
- package/dist/semantic-bridge/member-select.js.map +1 -1
- package/package.json +5 -5
|
@@ -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
|
|
@@ -41,6 +168,768 @@ export function inferAnalyticalTurnKind(question) {
|
|
|
41
168
|
return 'lookup';
|
|
42
169
|
return 'aggregation';
|
|
43
170
|
}
|
|
171
|
+
function normalizeRequirementTerm(value) {
|
|
172
|
+
return value.toLowerCase()
|
|
173
|
+
.replace(/[_./:-]+/g, ' ')
|
|
174
|
+
.replace(/[^a-z0-9 ]+/g, ' ')
|
|
175
|
+
.replace(/\s+/g, ' ')
|
|
176
|
+
.trim();
|
|
177
|
+
}
|
|
178
|
+
function uniqueRequirementTerms(values) {
|
|
179
|
+
return [...new Set(values
|
|
180
|
+
.filter((value) => typeof value === 'string')
|
|
181
|
+
.map(normalizeRequirementTerm)
|
|
182
|
+
.filter(Boolean))];
|
|
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
|
+
}
|
|
220
|
+
function isTemporalTerm(term) {
|
|
221
|
+
return /^(?:date|day|week|month|quarter|year|fy\d{2,4}|fiscal year|fiscal quarter)$/.test(term);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Parser output occasionally retains the grammatical wrapper around an
|
|
225
|
+
* aggregation (for example "count for each customer") as though it were a
|
|
226
|
+
* second metric. The stable requirement is `count`; the rest describes the
|
|
227
|
+
* requested grain and is already represented by the entity/dimension roles.
|
|
228
|
+
* Keeping the wrapper makes a physically complete customer table look
|
|
229
|
+
* incomplete and prematurely terminates the pre-freeze cascade.
|
|
230
|
+
*/
|
|
231
|
+
function isStructuralMeasurePhrase(value) {
|
|
232
|
+
const term = normalizeRequirementTerm(value);
|
|
233
|
+
return /^(?:count|sum|total|average|avg)?\s*for\s+(?:each|every)\s+(?:account|customer|client|company|product|order|item|row)s?$/.test(term)
|
|
234
|
+
|| /^for\s+(?:each|every)\s+(?:account|customer|client|company|product|order|item|row)s?$/.test(term);
|
|
235
|
+
}
|
|
236
|
+
const AGGREGATION_REQUIREMENT_OPERATORS = new Set([
|
|
237
|
+
'total', 'sum', 'average', 'avg', 'minimum', 'min', 'maximum', 'max',
|
|
238
|
+
]);
|
|
239
|
+
/**
|
|
240
|
+
* A parsed intent may preserve every grammatical fragment of an aggregate
|
|
241
|
+
* request ("total", "total supply cost", "supply", and "product"). Those
|
|
242
|
+
* fragments are useful while retrieving, but they are not independent
|
|
243
|
+
* physical requirements. Normalize only the explicit aggregation + grouping
|
|
244
|
+
* construction so ordinary named metrics such as `total_revenue` keep their
|
|
245
|
+
* authored identity.
|
|
246
|
+
*/
|
|
247
|
+
function typedAggregationRequirementRoles(question) {
|
|
248
|
+
const match = /\b(?:total|sum|average|avg|minimum|min|maximum|max)\s+([a-z][a-z0-9_ -]{1,60}?)\s+(?:per|by|for\s+each)\s+([a-z][a-z0-9_-]*)\b/i.exec(question);
|
|
249
|
+
const measure = match?.[1] ? normalizeRequirementTerm(match[1]) : '';
|
|
250
|
+
const dimension = match?.[2] ? normalizeRequirementTerm(match[2]) : '';
|
|
251
|
+
return measure && dimension ? { measure, dimension } : undefined;
|
|
252
|
+
}
|
|
253
|
+
function normalizedTypedAggregationRequirements(input) {
|
|
254
|
+
const typed = typedAggregationRequirementRoles(input.question);
|
|
255
|
+
if (!typed)
|
|
256
|
+
return input;
|
|
257
|
+
const measureParts = new Set(typed.measure.split(' ').filter(Boolean));
|
|
258
|
+
const measures = uniqueRequirementTerms([
|
|
259
|
+
typed.measure,
|
|
260
|
+
...input.measures.filter((value) => {
|
|
261
|
+
const normalized = normalizeRequirementTerm(value);
|
|
262
|
+
return normalized !== typed.measure
|
|
263
|
+
&& !AGGREGATION_REQUIREMENT_OPERATORS.has(normalized)
|
|
264
|
+
&& normalized !== `total ${typed.measure}`
|
|
265
|
+
&& !(normalized.split(' ').length === 1 && measureParts.has(normalized));
|
|
266
|
+
}),
|
|
267
|
+
]);
|
|
268
|
+
const dimensions = uniqueRequirementTerms([
|
|
269
|
+
typed.dimension,
|
|
270
|
+
...input.dimensions.filter((value) => {
|
|
271
|
+
const normalized = normalizeRequirementTerm(value);
|
|
272
|
+
return normalized !== typed.dimension
|
|
273
|
+
&& !(normalized.split(' ').length === 1 && measureParts.has(normalized));
|
|
274
|
+
}),
|
|
275
|
+
]);
|
|
276
|
+
return { measures, dimensions };
|
|
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
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Normalize grammatical aggregation wrappers before they become a plan
|
|
317
|
+
* requirement. Retrieval/parser output is allowed to retain useful search
|
|
318
|
+
* phrases, but an immutable plan must never treat "count for each customer"
|
|
319
|
+
* or "for each customer" as separate physical measures. The grouping entity
|
|
320
|
+
* is represented by the dimension/entity roles instead.
|
|
321
|
+
*
|
|
322
|
+
* `order count for each customer` is the common prose form for a count
|
|
323
|
+
* aggregation at customer grain. Keep the aggregation (`count`) and remove
|
|
324
|
+
* the object noun (`order`) only for that exact grouped construction; a named
|
|
325
|
+
* metric such as `order_value` remains untouched.
|
|
326
|
+
*/
|
|
327
|
+
export function normalizeAnalyticalMeasureTerms(question, values, options = {}) {
|
|
328
|
+
const normalizedQuestion = normalizeRequirementTerm(question);
|
|
329
|
+
const groupedOrderCount = /\borders?\s+count\s+(?:for|per)\s+(?:each|every)\s+(?:the\s+)?(?:account|customer|client|company|product|order|item|row)s?\b/.test(normalizedQuestion);
|
|
330
|
+
// 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
|
+
};
|
|
338
|
+
const terms = values
|
|
339
|
+
.map(canonicalMeasureAlias)
|
|
340
|
+
.filter((value) => !isStructuralMeasurePhrase(value))
|
|
341
|
+
.filter((value) => !(groupedOrderCount && /^(?:order|orders)$/i.test(normalizeRequirementTerm(value))));
|
|
342
|
+
if (groupedOrderCount && !terms.some((value) => normalizeRequirementTerm(value) === 'count')) {
|
|
343
|
+
terms.push('count');
|
|
344
|
+
}
|
|
345
|
+
// An inherited measure can already be a stable semantic/dbt identity. Keep
|
|
346
|
+
// that identity intact for the planner/meaning handoff; matching and display
|
|
347
|
+
// have their own normalizers. Rewriting `total_consumption_units` to prose
|
|
348
|
+
// here lost the only sticky reference a measure-less refinement carried.
|
|
349
|
+
if (options.preserveIdentity) {
|
|
350
|
+
const seen = new Set();
|
|
351
|
+
return terms.flatMap((value) => {
|
|
352
|
+
const exact = value.replace(/\s+/g, ' ').trim();
|
|
353
|
+
const normalized = normalizeRequirementTerm(exact);
|
|
354
|
+
if (!exact || !normalized || seen.has(normalized))
|
|
355
|
+
return [];
|
|
356
|
+
seen.add(normalized);
|
|
357
|
+
return [exact];
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
return uniqueRequirementTerms(terms);
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Parsed measure phrases are the most specific typed evidence available before
|
|
364
|
+
* meaning resolution. A lexical root is useful only when the parser found no
|
|
365
|
+
* phrase that already owns it: adding both `beverage revenue` and `revenue`
|
|
366
|
+
* turns one requested metric into two and incorrectly rejects a block whose
|
|
367
|
+
* own declared output is `beverage_revenue`. The same holds for `order count`
|
|
368
|
+
* and its generic `count` root; grouped prose is normalized to `count` before
|
|
369
|
+
* this helper runs, so retaining both is neither necessary nor correct.
|
|
370
|
+
*/
|
|
371
|
+
function nonRedundantLexicalMeasureTerms(parsedMeasures, question) {
|
|
372
|
+
const lexical = ['revenue', 'refund', 'refunds', 'bcm', 'run rate', 'count']
|
|
373
|
+
.filter((term) => new RegExp(`\\b${term.replace(' ', '\\s+')}\\b`, 'i').test(question));
|
|
374
|
+
return lexical.filter((term) => {
|
|
375
|
+
if (term === 'count') {
|
|
376
|
+
return !parsedMeasures.some((measure) => normalizeRequirementTerm(measure).split(' ').includes('count'));
|
|
377
|
+
}
|
|
378
|
+
const token = term === 'refunds' ? 'refund' : term;
|
|
379
|
+
return !parsedMeasures.some((measure) => normalizeRequirementTerm(measure)
|
|
380
|
+
.split(' ')
|
|
381
|
+
.some((word) => word === token || (token === 'refund' && word === 'refunds')));
|
|
382
|
+
});
|
|
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
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Parse only stable analytical roles. This is purposefully narrower than an
|
|
423
|
+
* LLM interpretation: unknown business phrases remain available to the normal
|
|
424
|
+
* bounded meaning resolver instead of being guessed here.
|
|
425
|
+
*/
|
|
426
|
+
export function buildAnalyticalRequirementSet(input) {
|
|
427
|
+
const question = input.question;
|
|
428
|
+
const lower = question.toLowerCase();
|
|
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);
|
|
432
|
+
const grainMatch = lower.match(/\b(?:by|per|each)\s+(day|week|month|quarter|year)\b|\b(monthly|weekly|quarterly|yearly|daily)\b/i);
|
|
433
|
+
const grainWord = (grainMatch?.[1] ?? grainMatch?.[0]?.replace(/ly\b/i, '') ?? '').toLowerCase();
|
|
434
|
+
const grain = grainWord === 'daily' ? 'day'
|
|
435
|
+
: grainWord === 'weekly' ? 'week'
|
|
436
|
+
: grainWord === 'monthly' ? 'month'
|
|
437
|
+
: grainWord === 'quarterly' ? 'quarter'
|
|
438
|
+
: grainWord === 'yearly' ? 'year'
|
|
439
|
+
: /^(day|week|month|quarter|year)$/.test(grainWord) ? grainWord
|
|
440
|
+
: undefined;
|
|
441
|
+
const fiscal = lower.match(/\bfy\s?(\d{2,4})\b|\bfiscal\s+year\s+(\d{2,4})\b/i);
|
|
442
|
+
const fiscalPeriod = fiscal ? `FY${fiscal[1] ?? fiscal[2]}`.toUpperCase() : undefined;
|
|
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 ?? []);
|
|
446
|
+
const entityTerms = uniqueRequirementTerms([
|
|
447
|
+
...((lower.match(/\b(?:account|accounts|customer|customers|client|clients|company|companies)\b/g) ?? [])),
|
|
448
|
+
]).map((term) => term.replace(/s$/, ''));
|
|
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))
|
|
455
|
+
? uniqueRequirementTerms(entityTerms.map((term) => `${term} name`))
|
|
456
|
+
: [];
|
|
457
|
+
// "this amount" is a deictic reference to a prior result, not a request to
|
|
458
|
+
// choose an `amount` metric. Treating it as a new explicit measure made a
|
|
459
|
+
// compositional follow-up reject every otherwise-valid display/predicate
|
|
460
|
+
// option. Concrete metric words remain typed requirements, including the
|
|
461
|
+
// common revenue/refunds pair used by multi-metric requests.
|
|
462
|
+
const deicticAmount = /\b(?:this|that|the|such)\s+amount\b/i.test(question);
|
|
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)));
|
|
469
|
+
const parsedMeasuresWithLexicalTerms = uniqueRequirementTerms([
|
|
470
|
+
...parsedMeasures,
|
|
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
|
+
: []),
|
|
476
|
+
...(!deicticAmount
|
|
477
|
+
&& /\bamount\b/i.test(question)
|
|
478
|
+
&& !parsedMeasures.some((measure) => normalizeRequirementTerm(measure).split(' ').includes('amount'))
|
|
479
|
+
? ['amount']
|
|
480
|
+
: []),
|
|
481
|
+
]);
|
|
482
|
+
const typedRequirements = normalizedTypedAggregationRequirements({
|
|
483
|
+
question,
|
|
484
|
+
measures: parsedMeasuresWithLexicalTerms,
|
|
485
|
+
dimensions: requestedDimensions.filter((term) => !isTemporalTerm(term)),
|
|
486
|
+
});
|
|
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;
|
|
496
|
+
const dimensions = typedRequirements.dimensions;
|
|
497
|
+
const rankingMetricTerms = ranking ? measures : [];
|
|
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;
|
|
512
|
+
const time = grain || fiscalPeriod
|
|
513
|
+
? {
|
|
514
|
+
role: grain ? 'time_axis' : 'time_filter',
|
|
515
|
+
...(grain ? { grain: grain } : {}),
|
|
516
|
+
...(fiscalPeriod ? { fiscalPeriod } : {}),
|
|
517
|
+
requiresDeclaredFiscalCalendar: Boolean(fiscalPeriod),
|
|
518
|
+
}
|
|
519
|
+
: undefined;
|
|
520
|
+
return {
|
|
521
|
+
version: 1,
|
|
522
|
+
measures,
|
|
523
|
+
dimensions,
|
|
524
|
+
entityTerms,
|
|
525
|
+
entityDisplayTerms,
|
|
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' } : {}),
|
|
529
|
+
...(ranking
|
|
530
|
+
? {
|
|
531
|
+
ranking: {
|
|
532
|
+
metricTerms: rankingMetricTerms,
|
|
533
|
+
entityTerms,
|
|
534
|
+
direction: /bottom|lowest|least|cheapest/i.test(ranking[1] ?? '') ? 'bottom' : 'top',
|
|
535
|
+
limit: explicitLimit ?? 10,
|
|
536
|
+
defaultedLimit: explicitLimit === undefined,
|
|
537
|
+
},
|
|
538
|
+
}
|
|
539
|
+
: {}),
|
|
540
|
+
...(time ? { time } : {}),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
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([
|
|
576
|
+
candidate.id,
|
|
577
|
+
candidate.qualifiedId,
|
|
578
|
+
candidate.name,
|
|
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);
|
|
596
|
+
const roles = new Set();
|
|
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)
|
|
604
|
+
roles.add('metric');
|
|
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);
|
|
635
|
+
if (candidate.kind === 'sql_column' || candidate.kind === 'dbt_model' || candidate.kind === 'sql_table')
|
|
636
|
+
roles.add('context');
|
|
637
|
+
if (roles.size === 0)
|
|
638
|
+
roles.add('context');
|
|
639
|
+
return [...roles];
|
|
640
|
+
}
|
|
641
|
+
function candidateMatchesTerms(candidate, terms, options = {}) {
|
|
642
|
+
if (terms.length === 0)
|
|
643
|
+
return false;
|
|
644
|
+
const identity = uniqueRequirementTerms([
|
|
645
|
+
candidate.id,
|
|
646
|
+
candidate.qualifiedId,
|
|
647
|
+
candidate.name,
|
|
648
|
+
...(candidate.aliases ?? []),
|
|
649
|
+
...(candidate.dimensions ?? []),
|
|
650
|
+
]).join(' ');
|
|
651
|
+
if (terms.some((term) => identity.includes(term) || term.includes(identity)))
|
|
652
|
+
return true;
|
|
653
|
+
if (options.categoricalDimension === true
|
|
654
|
+
&& terms.some((term) => categoricalDimensionTermsMatch(term, identity)))
|
|
655
|
+
return true;
|
|
656
|
+
return options.categoricalDimension === true
|
|
657
|
+
&& candidateMatchesCategoricalDimensionRequirement(candidate, terms);
|
|
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
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* A categorical dimension may satisfy a requested business role only through
|
|
712
|
+
* its own snapshot-authored declaration. In particular, `location_name` is
|
|
713
|
+
* not a synonym for `region`: it can fill a region lane only when metadata
|
|
714
|
+
* explicitly says `alternative-for:region`, or when the dimension itself is
|
|
715
|
+
* declared with the semantic geography role. This protects admission from
|
|
716
|
+
* broad lexical geography expansion while retaining role-balanced recall.
|
|
717
|
+
*/
|
|
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
|
+
}
|
|
727
|
+
const facts = new Set((candidate.compatibilityFacts ?? [])
|
|
728
|
+
.map(normalizeRequirementTerm)
|
|
729
|
+
.filter(Boolean));
|
|
730
|
+
if (facts.size === 0)
|
|
731
|
+
return false;
|
|
732
|
+
const requestedRoles = [...new Set(terms.flatMap((term) => {
|
|
733
|
+
const normalized = normalizeRequirementTerm(term);
|
|
734
|
+
const terminal = normalized.split(' ').at(-1) ?? '';
|
|
735
|
+
return [normalized, terminal].filter(Boolean);
|
|
736
|
+
}))];
|
|
737
|
+
const hasDeclaredAlternative = requestedRoles.some((role) => facts.has(`alternative for ${role}`)
|
|
738
|
+
|| facts.has(`dimension alternative for ${role}`));
|
|
739
|
+
if (hasDeclaredAlternative)
|
|
740
|
+
return true;
|
|
741
|
+
const declaredGeography = facts.has('semantic role geography')
|
|
742
|
+
|| facts.has('semantic geography role');
|
|
743
|
+
return declaredGeography && requestedRoles.some((role) => role === 'region' || role === 'geography' || role === 'geographic');
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Keep an internal retrieval result broad while making the provider package
|
|
747
|
+
* role-balanced. Exact/alias matches stay pinned; each requested role gets up
|
|
748
|
+
* to two candidates before relevance fills remaining cards.
|
|
749
|
+
*/
|
|
750
|
+
export function selectRoleBalancedMeaningCandidates(input) {
|
|
751
|
+
const max = Math.max(1, Math.min(16, Math.floor(input.maxCandidates ?? 16)));
|
|
752
|
+
const ranked = [...new Map(input.candidates
|
|
753
|
+
.filter((candidate) => candidate.id.trim() && candidate.compatibility !== 'incompatible')
|
|
754
|
+
.map((candidate) => [candidate.id, candidate])).values()]
|
|
755
|
+
.sort((left, right) => Number(Boolean(right.exactMatch)) - Number(Boolean(left.exactMatch))
|
|
756
|
+
|| (right.relevanceScore ?? 0) - (left.relevanceScore ?? 0)
|
|
757
|
+
|| left.id.localeCompare(right.id));
|
|
758
|
+
const selected = [];
|
|
759
|
+
const add = (candidate) => {
|
|
760
|
+
if (!candidate || candidateConflictsWithExplicitRankingMeasure(candidate, input.requirements))
|
|
761
|
+
return;
|
|
762
|
+
if (selected.length < max && !selected.some((item) => item.id === candidate.id))
|
|
763
|
+
selected.push(candidate);
|
|
764
|
+
};
|
|
765
|
+
const servesRequestedRole = (candidate) => {
|
|
766
|
+
const roles = evidenceCandidateRoles(candidate);
|
|
767
|
+
const metricTerms = input.requirements.ranking?.metricTerms.length
|
|
768
|
+
? input.requirements.ranking.metricTerms
|
|
769
|
+
: input.requirements.measures;
|
|
770
|
+
if (roles.includes('metric') && candidateMatchesTerms(candidate, metricTerms))
|
|
771
|
+
return true;
|
|
772
|
+
// An entity term such as "account" is deliberately insufficient for an
|
|
773
|
+
// attribute (Account Owner Email) to displace the requested display key.
|
|
774
|
+
// Only an actual entity-label candidate may satisfy this binding.
|
|
775
|
+
if (roles.includes('entity_label') && candidateMatchesTerms(candidate, [
|
|
776
|
+
...input.requirements.entityTerms,
|
|
777
|
+
...input.requirements.entityDisplayTerms,
|
|
778
|
+
]))
|
|
779
|
+
return true;
|
|
780
|
+
if (roles.includes('time_dimension') && Boolean(input.requirements.time))
|
|
781
|
+
return true;
|
|
782
|
+
const categoricalTerms = categoricalDimensionRequirementTerms(input.requirements);
|
|
783
|
+
if (roles.includes('categorical_dimension')
|
|
784
|
+
&& categoricalTerms.length > 0
|
|
785
|
+
&& candidateMatchesTerms(candidate, categoricalTerms, { categoricalDimension: true }))
|
|
786
|
+
return true;
|
|
787
|
+
if (roles.includes('relationship')
|
|
788
|
+
&& (input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0))
|
|
789
|
+
return true;
|
|
790
|
+
return false;
|
|
791
|
+
};
|
|
792
|
+
for (const candidate of ranked.filter((candidate) => candidate.exactMatch)) {
|
|
793
|
+
// In a pin-only prepass, an exact match is only a pin when it serves a
|
|
794
|
+
// requested analytical role. Otherwise a pile of exact members consumes
|
|
795
|
+
// the whole package before the requested metric/entity can be reserved.
|
|
796
|
+
if (input.pinOnly && !servesRequestedRole(candidate))
|
|
797
|
+
continue;
|
|
798
|
+
add(candidate);
|
|
799
|
+
}
|
|
800
|
+
const required = [
|
|
801
|
+
['metric', input.requirements.ranking?.metricTerms.length ? input.requirements.ranking.metricTerms : input.requirements.measures],
|
|
802
|
+
['entity_label', [...input.requirements.entityTerms, ...input.requirements.entityDisplayTerms]],
|
|
803
|
+
['time_dimension', input.requirements.time ? [input.requirements.time.grain ?? 'time'] : []],
|
|
804
|
+
['categorical_dimension', categoricalDimensionRequirementTerms(input.requirements)],
|
|
805
|
+
['relationship', input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0 ? ['relationship'] : []],
|
|
806
|
+
];
|
|
807
|
+
for (const [role, terms] of required) {
|
|
808
|
+
// No requested categorical dimension means that high-scoring arbitrary
|
|
809
|
+
// members are noise, not a role reservation. This is the subtle path that
|
|
810
|
+
// used to admit Account Owner and Sentiment immediately after Account Name.
|
|
811
|
+
if (terms.length === 0)
|
|
812
|
+
continue;
|
|
813
|
+
let admitted = 0;
|
|
814
|
+
for (const candidate of ranked) {
|
|
815
|
+
if (admitted >= 2 || selected.length >= max)
|
|
816
|
+
break;
|
|
817
|
+
const roles = evidenceCandidateRoles(candidate);
|
|
818
|
+
if (!roles.includes(role))
|
|
819
|
+
continue;
|
|
820
|
+
// "top accounts" needs the account display key, not any field whose
|
|
821
|
+
// label happens to contain account. Once a display candidate is
|
|
822
|
+
// available, owner/e-mail/sentiment attributes are neither the entity
|
|
823
|
+
// role nor a useful categorical reservation unless the user explicitly
|
|
824
|
+
// named that attribute. This runs during the pre-cap pin pass so noisy
|
|
825
|
+
// same-kind cards cannot enter through the categorical role.
|
|
826
|
+
const explicitlyRequestsAttribute = hasEntityAttributeTerm([
|
|
827
|
+
...input.requirements.dimensions,
|
|
828
|
+
...input.requirements.entityTerms,
|
|
829
|
+
...input.requirements.entityDisplayTerms,
|
|
830
|
+
].join(' '));
|
|
831
|
+
const hasRequestedEntityLabel = ranked.some((item) => evidenceCandidateRoles(item).includes('entity_label')
|
|
832
|
+
&& candidateMatchesTerms(item, [
|
|
833
|
+
...input.requirements.entityTerms,
|
|
834
|
+
...input.requirements.entityDisplayTerms,
|
|
835
|
+
]));
|
|
836
|
+
if (role === 'categorical_dimension'
|
|
837
|
+
&& hasRequestedEntityLabel
|
|
838
|
+
&& !explicitlyRequestsAttribute
|
|
839
|
+
&& isEntityAttributeCandidate(candidate))
|
|
840
|
+
continue;
|
|
841
|
+
// For entity labels, role is more important than a lexical owner/email
|
|
842
|
+
// hit. For all other roles, prefer an identity matching the requested
|
|
843
|
+
// business term but retain a role candidate when the request is terse.
|
|
844
|
+
if (terms.length > 0 && !candidateMatchesTerms(candidate, terms, { categoricalDimension: role === 'categorical_dimension' })
|
|
845
|
+
&& role !== 'time_dimension' && role !== 'relationship' && role !== 'entity_label')
|
|
846
|
+
continue;
|
|
847
|
+
add(candidate);
|
|
848
|
+
admitted += 1;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
if (!input.pinOnly) {
|
|
852
|
+
for (const candidate of ranked)
|
|
853
|
+
add(candidate);
|
|
854
|
+
}
|
|
855
|
+
return selected;
|
|
856
|
+
}
|
|
857
|
+
export function classifyProviderFailure(input) {
|
|
858
|
+
const text = `${input.code ?? ''} ${input.message ?? ''}`.toLowerCase();
|
|
859
|
+
const cause = /cancel/.test(text) ? 'cancelled'
|
|
860
|
+
: /dispatch.?budget|provider_dispatch_budget/.test(text) ? 'dispatch_budget'
|
|
861
|
+
: /deadline.?insufficient|admission|soft.?target|provider_result_rows_(?:blocked|limit_exceeded)/.test(text) ? 'admission_denied'
|
|
862
|
+
: /run.?deadline|time limit/.test(text) ? 'run_deadline'
|
|
863
|
+
: /timeout|timed out/.test(text) ? 'provider_timeout'
|
|
864
|
+
: /401|403|api key|unauthori[sz]ed|auth(?:entication)?/.test(text) ? 'authentication'
|
|
865
|
+
: /model(?:[ _-]+|\s+).*not[ _-]?found|unknown model|model_not_found|404/.test(text) ? 'model_not_found'
|
|
866
|
+
: /429|rate[ _-]?limit|too many requests/.test(text) ? 'rate_limited'
|
|
867
|
+
: /\b5\d{2}\b|gateway/.test(text) ? 'gateway'
|
|
868
|
+
: /econn|network|fetch failed|not reachable|connection refused/.test(text) ? 'network'
|
|
869
|
+
: 'unknown';
|
|
870
|
+
const retryable = cause === 'rate_limited' || cause === 'gateway' || cause === 'network' || cause === 'provider_timeout';
|
|
871
|
+
const safeAction = retryable ? (cause === 'rate_limited' ? 'wait_and_retry' : 'retry_same_provider')
|
|
872
|
+
: cause === 'authentication' || cause === 'model_not_found' ? 'fix_provider_configuration'
|
|
873
|
+
: cause === 'cancelled' ? 'none'
|
|
874
|
+
: 'inspect_run';
|
|
875
|
+
const httpStatusClass = /\b(?:401|403|404|429)\b/.test(text) ? '4xx'
|
|
876
|
+
: /\b5\d{2}\b/.test(text) ? '5xx'
|
|
877
|
+
: undefined;
|
|
878
|
+
return {
|
|
879
|
+
version: 1,
|
|
880
|
+
cause,
|
|
881
|
+
phase: input.phase ?? 'unknown',
|
|
882
|
+
retryable,
|
|
883
|
+
safeAction,
|
|
884
|
+
...(httpStatusClass ? { httpStatusClass } : {}),
|
|
885
|
+
...(input.providerFingerprint ? { providerFingerprint: input.providerFingerprint } : {}),
|
|
886
|
+
...(input.modelFingerprint ? { modelFingerprint: input.modelFingerprint } : {}),
|
|
887
|
+
...(input.baseOriginFingerprint ? { baseOriginFingerprint: input.baseOriginFingerprint } : {}),
|
|
888
|
+
};
|
|
889
|
+
}
|
|
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
|
+
}
|
|
914
|
+
return {
|
|
915
|
+
version: 1,
|
|
916
|
+
...input,
|
|
917
|
+
sourceCoverage: input.sourceCoverage.map((coverage) => ({
|
|
918
|
+
...coverage,
|
|
919
|
+
version: 1,
|
|
920
|
+
candidateIds: [...new Set(coverage.candidateIds)].slice(0, 32),
|
|
921
|
+
})),
|
|
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
|
+
} : {}),
|
|
931
|
+
};
|
|
932
|
+
}
|
|
44
933
|
/**
|
|
45
934
|
* `Regarding: "Mr. Matthew Meyer"` and friends — a short label, a colon, and a
|
|
46
935
|
* quoted value, with nothing else in the clause. Deliberately narrow: it must
|
|
@@ -560,6 +1449,124 @@ export function buildResearchEvidenceLedger(input) {
|
|
|
560
1449
|
stoppingReason: input.stoppingReason ?? (entries.length > 0 ? 'completed' : 'not_started'),
|
|
561
1450
|
};
|
|
562
1451
|
}
|
|
1452
|
+
export function buildResearchEvidenceLedgerV2(input) {
|
|
1453
|
+
const v1 = buildResearchEvidenceLedger(input);
|
|
1454
|
+
const entries = v1.entries.map((entry, index) => {
|
|
1455
|
+
const source = input.entries[index];
|
|
1456
|
+
const validator = normalizeResearchEvidenceValidator(source?.validator, entry);
|
|
1457
|
+
const requestedVerdict = /\b(?:because|caused?|driven by|due to)\b/i.test(source?.hypothesis ?? '')
|
|
1458
|
+
? undefined
|
|
1459
|
+
: source?.verdict;
|
|
1460
|
+
const verdict = researchVerdictFromValidatedObservation({
|
|
1461
|
+
status: entry.status,
|
|
1462
|
+
requestedVerdict,
|
|
1463
|
+
validator,
|
|
1464
|
+
});
|
|
1465
|
+
const validFactIds = new Set(entry.facts);
|
|
1466
|
+
const counterEvidenceFactIds = [...new Set(source?.counterEvidenceFactIds ?? [])]
|
|
1467
|
+
.filter((factId) => validFactIds.has(factId));
|
|
1468
|
+
return {
|
|
1469
|
+
...entry,
|
|
1470
|
+
verdict,
|
|
1471
|
+
...(source?.hypothesis?.trim() ? { hypothesis: source.hypothesis.trim() } : {}),
|
|
1472
|
+
...(validator ? { validator } : {}),
|
|
1473
|
+
counterEvidenceFactIds,
|
|
1474
|
+
};
|
|
1475
|
+
});
|
|
1476
|
+
const observedGroundableBranchCount = entries.filter((entry) => entry.status === 'observed' && entry.verdict !== 'failed' && entry.verdict !== 'skipped').length;
|
|
1477
|
+
const plannedGroundableBranchCount = Math.max(0, Math.min(6, Math.trunc(input.groundableBranchCount ?? 0)));
|
|
1478
|
+
const groundableBranchCount = Math.max(observedGroundableBranchCount, plannedGroundableBranchCount);
|
|
1479
|
+
return {
|
|
1480
|
+
version: 2,
|
|
1481
|
+
rootQuestion: v1.rootQuestion,
|
|
1482
|
+
...(v1.planId ? { planId: v1.planId } : {}),
|
|
1483
|
+
...(v1.snapshotId ? { snapshotId: v1.snapshotId } : {}),
|
|
1484
|
+
entries,
|
|
1485
|
+
factIds: [...new Set(entries.flatMap((entry) => [...entry.facts, ...entry.counterEvidenceFactIds]))],
|
|
1486
|
+
groundableBranchCount,
|
|
1487
|
+
limitedScope: groundableBranchCount < 3,
|
|
1488
|
+
stoppingReason: v1.stoppingReason,
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Normalise a hypothesis plan into the bounded research contract. The caller
|
|
1493
|
+
* may supply fewer than three grounded hypotheses; that is retained honestly as
|
|
1494
|
+
* limited scope rather than padded with invented joins or explanations.
|
|
1495
|
+
*/
|
|
1496
|
+
export function buildResearchHypothesisPlanV2(input) {
|
|
1497
|
+
const seen = new Set();
|
|
1498
|
+
const hypotheses = [];
|
|
1499
|
+
for (const candidate of input.hypotheses) {
|
|
1500
|
+
const statement = candidate.statement.trim();
|
|
1501
|
+
const expectation = candidate.expectation.trim();
|
|
1502
|
+
const targetId = candidate.targetId.trim();
|
|
1503
|
+
if (!statement || !expectation || !targetId)
|
|
1504
|
+
continue;
|
|
1505
|
+
const key = `${statement.toLowerCase()}\u0000${targetId.toLowerCase()}`;
|
|
1506
|
+
if (seen.has(key))
|
|
1507
|
+
continue;
|
|
1508
|
+
seen.add(key);
|
|
1509
|
+
hypotheses.push({
|
|
1510
|
+
id: candidate.id?.trim() || `hypothesis:${hypotheses.length + 1}`,
|
|
1511
|
+
statement,
|
|
1512
|
+
expectation,
|
|
1513
|
+
targetId,
|
|
1514
|
+
validatorKind: candidate.validatorKind ?? inferResearchValidatorKind(statement, expectation),
|
|
1515
|
+
});
|
|
1516
|
+
if (hypotheses.length >= 6)
|
|
1517
|
+
break;
|
|
1518
|
+
}
|
|
1519
|
+
return { version: 2, hypotheses, limitedScope: hypotheses.length < 3 };
|
|
1520
|
+
}
|
|
1521
|
+
/** Map an action/expectation to a deterministic observation class only. */
|
|
1522
|
+
export function inferResearchValidatorKind(statement, expectation = '') {
|
|
1523
|
+
const text = `${statement} ${expectation}`.toLowerCase();
|
|
1524
|
+
if (/fresh|updated|stale|as of|recency/.test(text))
|
|
1525
|
+
return 'freshness';
|
|
1526
|
+
if (/contribut|driver|segment|breakdown|dominant/.test(text))
|
|
1527
|
+
return 'contributor';
|
|
1528
|
+
if (/trend|time|month|week|quarter|year|shift|change/.test(text))
|
|
1529
|
+
return 'trend';
|
|
1530
|
+
if (/compare|versus|vs\.?|difference/.test(text))
|
|
1531
|
+
return 'comparison';
|
|
1532
|
+
if (/anomal|outlier|spike|drop/.test(text))
|
|
1533
|
+
return 'anomaly';
|
|
1534
|
+
return 'counter_evidence';
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* A verdict is promoted only from a validator that evaluated a deterministic
|
|
1538
|
+
* observation against a branch receipt. Rows by themselves stay inconclusive;
|
|
1539
|
+
* causal statements are never supported by this helper.
|
|
1540
|
+
*/
|
|
1541
|
+
export function researchVerdictFromValidatedObservation(input) {
|
|
1542
|
+
if (input.status === 'failed')
|
|
1543
|
+
return 'failed';
|
|
1544
|
+
if (input.status === 'skipped')
|
|
1545
|
+
return 'skipped';
|
|
1546
|
+
if (!input.validator?.evaluated || input.validator.receiptFingerprints.length === 0)
|
|
1547
|
+
return 'inconclusive';
|
|
1548
|
+
if (input.requestedVerdict === 'supported' && input.validator.outcome === 'supports_observation')
|
|
1549
|
+
return 'supported';
|
|
1550
|
+
if (input.requestedVerdict === 'contradicted' && input.validator.outcome === 'contradicts_observation')
|
|
1551
|
+
return 'contradicted';
|
|
1552
|
+
return 'inconclusive';
|
|
1553
|
+
}
|
|
1554
|
+
function normalizeResearchEvidenceValidator(validator, entry) {
|
|
1555
|
+
if (!validator || validator.version !== 1)
|
|
1556
|
+
return undefined;
|
|
1557
|
+
const knownReceipt = entry.resultFingerprint;
|
|
1558
|
+
const receiptFingerprints = [...new Set(validator.receiptFingerprints)]
|
|
1559
|
+
.map(normalizeAnalyticalExecutionFingerprint)
|
|
1560
|
+
.filter((fingerprint) => Boolean(fingerprint))
|
|
1561
|
+
.filter((fingerprint) => !knownReceipt || fingerprint === knownReceipt);
|
|
1562
|
+
return {
|
|
1563
|
+
version: 1,
|
|
1564
|
+
kind: validator.kind,
|
|
1565
|
+
evaluated: validator.evaluated === true && receiptFingerprints.length > 0,
|
|
1566
|
+
...(validator.outcome ? { outcome: validator.outcome } : {}),
|
|
1567
|
+
receiptFingerprints,
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
563
1570
|
/** The only accepted host-side execution identity is a SHA-256 fingerprint. */
|
|
564
1571
|
export function normalizeAnalyticalExecutionFingerprint(value) {
|
|
565
1572
|
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value.trim())
|