@duckcodeailabs/dql-agent 1.14.0 → 1.14.2
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 +68 -2
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +520 -26
- 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 +25 -0
- package/dist/agent-run-gates.js.map +1 -1
- package/dist/agentic/analyst-loop.d.ts +91 -0
- package/dist/agentic/analyst-loop.d.ts.map +1 -0
- package/dist/agentic/analyst-loop.js +331 -0
- package/dist/agentic/analyst-loop.js.map +1 -0
- package/dist/agentic/assumptions.d.ts +62 -0
- package/dist/agentic/assumptions.d.ts.map +1 -0
- package/dist/agentic/assumptions.js +73 -0
- package/dist/agentic/assumptions.js.map +1 -0
- package/dist/agentic/business-explanation.d.ts +56 -0
- package/dist/agentic/business-explanation.d.ts.map +1 -0
- package/dist/agentic/business-explanation.js +130 -0
- package/dist/agentic/business-explanation.js.map +1 -0
- package/dist/agentic/identifier-ledger.d.ts +74 -0
- package/dist/agentic/identifier-ledger.d.ts.map +1 -0
- package/dist/agentic/identifier-ledger.js +151 -0
- package/dist/agentic/identifier-ledger.js.map +1 -0
- package/dist/agentic/ledger-tools.d.ts +68 -0
- package/dist/agentic/ledger-tools.d.ts.map +1 -0
- package/dist/agentic/ledger-tools.js +198 -0
- package/dist/agentic/ledger-tools.js.map +1 -0
- package/dist/agentic/orchestrator-policy.d.ts +60 -0
- package/dist/agentic/orchestrator-policy.d.ts.map +1 -0
- package/dist/agentic/orchestrator-policy.js +73 -0
- package/dist/agentic/orchestrator-policy.js.map +1 -0
- package/dist/agentic/orchestrator.d.ts +54 -0
- package/dist/agentic/orchestrator.d.ts.map +1 -0
- package/dist/agentic/orchestrator.js +67 -0
- package/dist/agentic/orchestrator.js.map +1 -0
- package/dist/agentic/preview-tool.d.ts +36 -0
- package/dist/agentic/preview-tool.d.ts.map +1 -0
- package/dist/agentic/preview-tool.js +71 -0
- package/dist/agentic/preview-tool.js.map +1 -0
- package/dist/agentic/rerank.d.ts +50 -0
- package/dist/agentic/rerank.d.ts.map +1 -0
- package/dist/agentic/rerank.js +140 -0
- package/dist/agentic/rerank.js.map +1 -0
- package/dist/agentic/research/hypothesis.d.ts +110 -0
- package/dist/agentic/research/hypothesis.d.ts.map +1 -0
- package/dist/agentic/research/hypothesis.js +125 -0
- package/dist/agentic/research/hypothesis.js.map +1 -0
- package/dist/agentic/research/synthesis.d.ts +36 -0
- package/dist/agentic/research/synthesis.d.ts.map +1 -0
- package/dist/agentic/research/synthesis.js +109 -0
- package/dist/agentic/research/synthesis.js.map +1 -0
- package/dist/agentic/research-agent.d.ts +54 -0
- package/dist/agentic/research-agent.d.ts.map +1 -0
- package/dist/agentic/research-agent.js +177 -0
- package/dist/agentic/research-agent.js.map +1 -0
- package/dist/agentic/sql-authorization.d.ts +131 -0
- package/dist/agentic/sql-authorization.d.ts.map +1 -0
- package/dist/agentic/sql-authorization.js +460 -0
- package/dist/agentic/sql-authorization.js.map +1 -0
- package/dist/agentic/tool-loop.d.ts +29 -0
- package/dist/agentic/tool-loop.d.ts.map +1 -1
- package/dist/agentic/tool-loop.js +129 -52
- package/dist/agentic/tool-loop.js.map +1 -1
- package/dist/agentic/toolset.d.ts.map +1 -1
- package/dist/agentic/toolset.js +155 -0
- package/dist/agentic/toolset.js.map +1 -1
- package/dist/agentic/turn-plan.d.ts +35 -0
- package/dist/agentic/turn-plan.d.ts.map +1 -0
- package/dist/agentic/turn-plan.js +108 -0
- package/dist/agentic/turn-plan.js.map +1 -0
- package/dist/agentic/value-probe.d.ts +89 -0
- package/dist/agentic/value-probe.d.ts.map +1 -0
- package/dist/agentic/value-probe.js +304 -0
- package/dist/agentic/value-probe.js.map +1 -0
- package/dist/analytical-frame.d.ts.map +1 -1
- package/dist/analytical-frame.js +10 -1
- package/dist/analytical-frame.js.map +1 -1
- package/dist/analytical-orchestration.d.ts +382 -0
- package/dist/analytical-orchestration.d.ts.map +1 -1
- package/dist/analytical-orchestration.js +782 -6
- package/dist/analytical-orchestration.js.map +1 -1
- package/dist/analytical-request-policy.d.ts +26 -0
- package/dist/analytical-request-policy.d.ts.map +1 -0
- package/dist/analytical-request-policy.js +82 -0
- package/dist/analytical-request-policy.js.map +1 -0
- package/dist/analytical-result-facts.d.ts +13 -0
- package/dist/analytical-result-facts.d.ts.map +1 -1
- package/dist/analytical-result-facts.js +37 -1
- package/dist/analytical-result-facts.js.map +1 -1
- package/dist/answer-loop.d.ts +105 -16
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +833 -108
- 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/cascade/budgets.d.ts.map +1 -1
- package/dist/cascade/budgets.js +8 -1
- package/dist/cascade/budgets.js.map +1 -1
- package/dist/cascade/packer.d.ts +88 -0
- package/dist/cascade/packer.d.ts.map +1 -0
- package/dist/cascade/packer.js +106 -0
- package/dist/cascade/packer.js.map +1 -0
- package/dist/conversation/result-ops.d.ts +18 -3
- package/dist/conversation/result-ops.d.ts.map +1 -1
- package/dist/conversation/result-ops.js +77 -5
- package/dist/conversation/result-ops.js.map +1 -1
- package/dist/conversation/rolling-summary.d.ts.map +1 -1
- package/dist/conversation/rolling-summary.js +7 -2
- package/dist/conversation/rolling-summary.js.map +1 -1
- package/dist/conversation/session-store.d.ts +3 -0
- package/dist/conversation/session-store.d.ts.map +1 -1
- package/dist/conversation/session-store.js +8 -4
- package/dist/conversation/session-store.js.map +1 -1
- package/dist/conversation/snapshot.d.ts +20 -0
- package/dist/conversation/snapshot.d.ts.map +1 -1
- package/dist/conversation/snapshot.js +40 -2
- 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/hints/store.d.ts.map +1 -1
- package/dist/hints/store.js +15 -5
- package/dist/hints/store.js.map +1 -1
- package/dist/index.d.ts +29 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -6
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +36 -0
- package/dist/intent-controller.d.ts.map +1 -1
- package/dist/intent-controller.js +95 -0
- package/dist/intent-controller.js.map +1 -1
- package/dist/meaning-resolution.d.ts +69 -2
- package/dist/meaning-resolution.d.ts.map +1 -1
- package/dist/meaning-resolution.js +128 -7
- package/dist/meaning-resolution.js.map +1 -1
- package/dist/metadata/analysis-planner.d.ts.map +1 -1
- package/dist/metadata/analysis-planner.js +124 -7
- package/dist/metadata/analysis-planner.js.map +1 -1
- package/dist/metadata/block-fit.d.ts +36 -0
- package/dist/metadata/block-fit.d.ts.map +1 -1
- package/dist/metadata/block-fit.js +290 -38
- package/dist/metadata/block-fit.js.map +1 -1
- package/dist/metadata/catalog.d.ts +20 -23
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +193 -22
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/meaning-evidence.d.ts +10 -1
- package/dist/metadata/meaning-evidence.d.ts.map +1 -1
- package/dist/metadata/meaning-evidence.js +275 -53
- package/dist/metadata/meaning-evidence.js.map +1 -1
- package/dist/metadata/metric-match.d.ts +32 -0
- package/dist/metadata/metric-match.d.ts.map +1 -1
- package/dist/metadata/metric-match.js +120 -9
- package/dist/metadata/metric-match.js.map +1 -1
- package/dist/research-loop.d.ts +33 -2
- package/dist/research-loop.d.ts.map +1 -1
- package/dist/research-loop.js +66 -4
- package/dist/research-loop.js.map +1 -1
- package/dist/resolved-analytical-plan.d.ts +7 -0
- package/dist/resolved-analytical-plan.d.ts.map +1 -1
- package/dist/resolved-analytical-plan.js +11 -4
- package/dist/resolved-analytical-plan.js.map +1 -1
- package/dist/router.d.ts +32 -19
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +1977 -73
- package/dist/router.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
|
@@ -41,6 +41,459 @@ export function inferAnalyticalTurnKind(question) {
|
|
|
41
41
|
return 'lookup';
|
|
42
42
|
return 'aggregation';
|
|
43
43
|
}
|
|
44
|
+
function normalizeRequirementTerm(value) {
|
|
45
|
+
return value.toLowerCase()
|
|
46
|
+
.replace(/[_./:-]+/g, ' ')
|
|
47
|
+
.replace(/[^a-z0-9 ]+/g, ' ')
|
|
48
|
+
.replace(/\s+/g, ' ')
|
|
49
|
+
.trim();
|
|
50
|
+
}
|
|
51
|
+
function uniqueRequirementTerms(values) {
|
|
52
|
+
return [...new Set(values
|
|
53
|
+
.filter((value) => typeof value === 'string')
|
|
54
|
+
.map(normalizeRequirementTerm)
|
|
55
|
+
.filter(Boolean))];
|
|
56
|
+
}
|
|
57
|
+
function isTemporalTerm(term) {
|
|
58
|
+
return /^(?:date|day|week|month|quarter|year|fy\d{2,4}|fiscal year|fiscal quarter)$/.test(term);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Parser output occasionally retains the grammatical wrapper around an
|
|
62
|
+
* aggregation (for example "count for each customer") as though it were a
|
|
63
|
+
* second metric. The stable requirement is `count`; the rest describes the
|
|
64
|
+
* requested grain and is already represented by the entity/dimension roles.
|
|
65
|
+
* Keeping the wrapper makes a physically complete customer table look
|
|
66
|
+
* incomplete and prematurely terminates the pre-freeze cascade.
|
|
67
|
+
*/
|
|
68
|
+
function isStructuralMeasurePhrase(value) {
|
|
69
|
+
const term = normalizeRequirementTerm(value);
|
|
70
|
+
return /^(?:count|sum|total|average|avg)?\s*for\s+(?:each|every)\s+(?:account|customer|client|company|product|order|item|row)s?$/.test(term)
|
|
71
|
+
|| /^for\s+(?:each|every)\s+(?:account|customer|client|company|product|order|item|row)s?$/.test(term);
|
|
72
|
+
}
|
|
73
|
+
const AGGREGATION_REQUIREMENT_OPERATORS = new Set([
|
|
74
|
+
'total', 'sum', 'average', 'avg', 'minimum', 'min', 'maximum', 'max',
|
|
75
|
+
]);
|
|
76
|
+
/**
|
|
77
|
+
* A parsed intent may preserve every grammatical fragment of an aggregate
|
|
78
|
+
* request ("total", "total supply cost", "supply", and "product"). Those
|
|
79
|
+
* fragments are useful while retrieving, but they are not independent
|
|
80
|
+
* physical requirements. Normalize only the explicit aggregation + grouping
|
|
81
|
+
* construction so ordinary named metrics such as `total_revenue` keep their
|
|
82
|
+
* authored identity.
|
|
83
|
+
*/
|
|
84
|
+
function typedAggregationRequirementRoles(question) {
|
|
85
|
+
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);
|
|
86
|
+
const measure = match?.[1] ? normalizeRequirementTerm(match[1]) : '';
|
|
87
|
+
const dimension = match?.[2] ? normalizeRequirementTerm(match[2]) : '';
|
|
88
|
+
return measure && dimension ? { measure, dimension } : undefined;
|
|
89
|
+
}
|
|
90
|
+
function normalizedTypedAggregationRequirements(input) {
|
|
91
|
+
const typed = typedAggregationRequirementRoles(input.question);
|
|
92
|
+
if (!typed)
|
|
93
|
+
return input;
|
|
94
|
+
const measureParts = new Set(typed.measure.split(' ').filter(Boolean));
|
|
95
|
+
const measures = uniqueRequirementTerms([
|
|
96
|
+
typed.measure,
|
|
97
|
+
...input.measures.filter((value) => {
|
|
98
|
+
const normalized = normalizeRequirementTerm(value);
|
|
99
|
+
return normalized !== typed.measure
|
|
100
|
+
&& !AGGREGATION_REQUIREMENT_OPERATORS.has(normalized)
|
|
101
|
+
&& normalized !== `total ${typed.measure}`
|
|
102
|
+
&& !(normalized.split(' ').length === 1 && measureParts.has(normalized));
|
|
103
|
+
}),
|
|
104
|
+
]);
|
|
105
|
+
const dimensions = uniqueRequirementTerms([
|
|
106
|
+
typed.dimension,
|
|
107
|
+
...input.dimensions.filter((value) => {
|
|
108
|
+
const normalized = normalizeRequirementTerm(value);
|
|
109
|
+
return normalized !== typed.dimension
|
|
110
|
+
&& !(normalized.split(' ').length === 1 && measureParts.has(normalized));
|
|
111
|
+
}),
|
|
112
|
+
]);
|
|
113
|
+
return { measures, dimensions };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Normalize grammatical aggregation wrappers before they become a plan
|
|
117
|
+
* requirement. Retrieval/parser output is allowed to retain useful search
|
|
118
|
+
* phrases, but an immutable plan must never treat "count for each customer"
|
|
119
|
+
* or "for each customer" as separate physical measures. The grouping entity
|
|
120
|
+
* is represented by the dimension/entity roles instead.
|
|
121
|
+
*
|
|
122
|
+
* `order count for each customer` is the common prose form for a count
|
|
123
|
+
* aggregation at customer grain. Keep the aggregation (`count`) and remove
|
|
124
|
+
* the object noun (`order`) only for that exact grouped construction; a named
|
|
125
|
+
* metric such as `order_value` remains untouched.
|
|
126
|
+
*/
|
|
127
|
+
export function normalizeAnalyticalMeasureTerms(question, values, options = {}) {
|
|
128
|
+
const normalizedQuestion = normalizeRequirementTerm(question);
|
|
129
|
+
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);
|
|
130
|
+
const terms = values
|
|
131
|
+
.filter((value) => !isStructuralMeasurePhrase(value))
|
|
132
|
+
.filter((value) => !(groupedOrderCount && /^(?:order|orders)$/i.test(normalizeRequirementTerm(value))));
|
|
133
|
+
if (groupedOrderCount && !terms.some((value) => normalizeRequirementTerm(value) === 'count')) {
|
|
134
|
+
terms.push('count');
|
|
135
|
+
}
|
|
136
|
+
// An inherited measure can already be a stable semantic/dbt identity. Keep
|
|
137
|
+
// that identity intact for the planner/meaning handoff; matching and display
|
|
138
|
+
// have their own normalizers. Rewriting `total_consumption_units` to prose
|
|
139
|
+
// here lost the only sticky reference a measure-less refinement carried.
|
|
140
|
+
if (options.preserveIdentity) {
|
|
141
|
+
const seen = new Set();
|
|
142
|
+
return terms.flatMap((value) => {
|
|
143
|
+
const exact = value.replace(/\s+/g, ' ').trim();
|
|
144
|
+
const normalized = normalizeRequirementTerm(exact);
|
|
145
|
+
if (!exact || !normalized || seen.has(normalized))
|
|
146
|
+
return [];
|
|
147
|
+
seen.add(normalized);
|
|
148
|
+
return [exact];
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return uniqueRequirementTerms(terms);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Parsed measure phrases are the most specific typed evidence available before
|
|
155
|
+
* meaning resolution. A lexical root is useful only when the parser found no
|
|
156
|
+
* phrase that already owns it: adding both `beverage revenue` and `revenue`
|
|
157
|
+
* turns one requested metric into two and incorrectly rejects a block whose
|
|
158
|
+
* own declared output is `beverage_revenue`. The same holds for `order count`
|
|
159
|
+
* and its generic `count` root; grouped prose is normalized to `count` before
|
|
160
|
+
* this helper runs, so retaining both is neither necessary nor correct.
|
|
161
|
+
*/
|
|
162
|
+
function nonRedundantLexicalMeasureTerms(parsedMeasures, question) {
|
|
163
|
+
const lexical = ['revenue', 'refund', 'refunds', 'bcm', 'run rate', 'count']
|
|
164
|
+
.filter((term) => new RegExp(`\\b${term.replace(' ', '\\s+')}\\b`, 'i').test(question));
|
|
165
|
+
return lexical.filter((term) => {
|
|
166
|
+
if (term === 'count') {
|
|
167
|
+
return !parsedMeasures.some((measure) => normalizeRequirementTerm(measure).split(' ').includes('count'));
|
|
168
|
+
}
|
|
169
|
+
const token = term === 'refunds' ? 'refund' : term;
|
|
170
|
+
return !parsedMeasures.some((measure) => normalizeRequirementTerm(measure)
|
|
171
|
+
.split(' ')
|
|
172
|
+
.some((word) => word === token || (token === 'refund' && word === 'refunds')));
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Parse only stable analytical roles. This is purposefully narrower than an
|
|
177
|
+
* LLM interpretation: unknown business phrases remain available to the normal
|
|
178
|
+
* bounded meaning resolver instead of being guessed here.
|
|
179
|
+
*/
|
|
180
|
+
export function buildAnalyticalRequirementSet(input) {
|
|
181
|
+
const question = input.question;
|
|
182
|
+
const lower = question.toLowerCase();
|
|
183
|
+
const parsed = input.parsedIntent;
|
|
184
|
+
const grainMatch = lower.match(/\b(?:by|per|each)\s+(day|week|month|quarter|year)\b|\b(monthly|weekly|quarterly|yearly|daily)\b/i);
|
|
185
|
+
const grainWord = (grainMatch?.[1] ?? grainMatch?.[0]?.replace(/ly\b/i, '') ?? '').toLowerCase();
|
|
186
|
+
const grain = grainWord === 'daily' ? 'day'
|
|
187
|
+
: grainWord === 'weekly' ? 'week'
|
|
188
|
+
: grainWord === 'monthly' ? 'month'
|
|
189
|
+
: grainWord === 'quarterly' ? 'quarter'
|
|
190
|
+
: grainWord === 'yearly' ? 'year'
|
|
191
|
+
: /^(day|week|month|quarter|year)$/.test(grainWord) ? grainWord
|
|
192
|
+
: undefined;
|
|
193
|
+
const fiscal = lower.match(/\bfy\s?(\d{2,4})\b|\bfiscal\s+year\s+(\d{2,4})\b/i);
|
|
194
|
+
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 requestedDimensions = uniqueRequirementTerms(parsed?.dimensions ?? []);
|
|
197
|
+
const entityTerms = uniqueRequirementTerms([
|
|
198
|
+
...((lower.match(/\b(?:account|accounts|customer|customers|client|clients|company|companies)\b/g) ?? [])),
|
|
199
|
+
]).map((term) => term.replace(/s$/, ''));
|
|
200
|
+
const entityDisplayTerms = /\b(?:who|which)\b/i.test(question)
|
|
201
|
+
? uniqueRequirementTerms(entityTerms.map((term) => `${term} name`))
|
|
202
|
+
: [];
|
|
203
|
+
// "this amount" is a deictic reference to a prior result, not a request to
|
|
204
|
+
// choose an `amount` metric. Treating it as a new explicit measure made a
|
|
205
|
+
// compositional follow-up reject every otherwise-valid display/predicate
|
|
206
|
+
// option. Concrete metric words remain typed requirements, including the
|
|
207
|
+
// common revenue/refunds pair used by multi-metric requests.
|
|
208
|
+
const deicticAmount = /\b(?:this|that|the|such)\s+amount\b/i.test(question);
|
|
209
|
+
const parsedMeasures = normalizeAnalyticalMeasureTerms(question, parsed?.measures ?? []);
|
|
210
|
+
const parsedMeasuresWithLexicalTerms = uniqueRequirementTerms([
|
|
211
|
+
...parsedMeasures,
|
|
212
|
+
...nonRedundantLexicalMeasureTerms(parsedMeasures, question),
|
|
213
|
+
...(!deicticAmount
|
|
214
|
+
&& /\bamount\b/i.test(question)
|
|
215
|
+
&& !parsedMeasures.some((measure) => normalizeRequirementTerm(measure).split(' ').includes('amount'))
|
|
216
|
+
? ['amount']
|
|
217
|
+
: []),
|
|
218
|
+
]);
|
|
219
|
+
const typedRequirements = normalizedTypedAggregationRequirements({
|
|
220
|
+
question,
|
|
221
|
+
measures: parsedMeasuresWithLexicalTerms,
|
|
222
|
+
dimensions: requestedDimensions.filter((term) => !isTemporalTerm(term)),
|
|
223
|
+
});
|
|
224
|
+
const measures = typedRequirements.measures;
|
|
225
|
+
const dimensions = typedRequirements.dimensions;
|
|
226
|
+
const rankingMetricTerms = ranking ? measures : [];
|
|
227
|
+
const parsedLimit = typeof parsed?.limit === 'number' && Number.isFinite(parsed.limit) && parsed.limit > 0
|
|
228
|
+
? Math.floor(parsed.limit)
|
|
229
|
+
: undefined;
|
|
230
|
+
const explicitLimit = ranking?.[2] ? Number(ranking[2]) : parsedLimit;
|
|
231
|
+
const time = grain || fiscalPeriod
|
|
232
|
+
? {
|
|
233
|
+
role: grain ? 'time_axis' : 'time_filter',
|
|
234
|
+
...(grain ? { grain: grain } : {}),
|
|
235
|
+
...(fiscalPeriod ? { fiscalPeriod } : {}),
|
|
236
|
+
requiresDeclaredFiscalCalendar: Boolean(fiscalPeriod),
|
|
237
|
+
}
|
|
238
|
+
: undefined;
|
|
239
|
+
return {
|
|
240
|
+
version: 1,
|
|
241
|
+
measures,
|
|
242
|
+
dimensions,
|
|
243
|
+
entityTerms,
|
|
244
|
+
entityDisplayTerms,
|
|
245
|
+
memberTerms: uniqueRequirementTerms((parsed?.filters ?? []).map((filter) => filter.value)),
|
|
246
|
+
...(ranking
|
|
247
|
+
? {
|
|
248
|
+
ranking: {
|
|
249
|
+
metricTerms: rankingMetricTerms,
|
|
250
|
+
entityTerms,
|
|
251
|
+
direction: /bottom|lowest/i.test(ranking[1] ?? '') ? 'bottom' : 'top',
|
|
252
|
+
limit: explicitLimit ?? 10,
|
|
253
|
+
defaultedLimit: explicitLimit === undefined,
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
: {}),
|
|
257
|
+
...(time ? { time } : {}),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
/** Classify the role an already-qualified candidate may fill. */
|
|
261
|
+
export function evidenceCandidateRoles(candidate) {
|
|
262
|
+
const identity = uniqueRequirementTerms([
|
|
263
|
+
candidate.id,
|
|
264
|
+
candidate.qualifiedId,
|
|
265
|
+
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
|
+
]).join(' ');
|
|
271
|
+
const roles = new Set();
|
|
272
|
+
if (candidate.kind === 'semantic_metric' || candidate.semanticObjectType === 'metric' || /\bmetric\b/.test(identity))
|
|
273
|
+
roles.add('metric');
|
|
274
|
+
if (candidate.semanticObjectType === 'entity' || /(?:^| )(?:account|customer|client|company) id\b/.test(identity) || /\bentity\b/.test(identity))
|
|
275
|
+
roles.add('entity_key');
|
|
276
|
+
if (/\b(?:account|customer|client|company)(?: name)?\b/.test(identity)
|
|
277
|
+
&& /\b(?:name|label|display|account|customer|client|company)\b/.test(identity)
|
|
278
|
+
&& !/\b(?:owner|sentiment|email)\b/.test(identity))
|
|
279
|
+
roles.add('entity_label');
|
|
280
|
+
if (/(?:\bdate\b|\btime\b|\bmonth\b|\bquarter\b|\byear\b)/.test(identity)
|
|
281
|
+
|| (candidate.timeGrains?.length ?? 0) > 0
|
|
282
|
+
|| (candidate.analyticalCapability?.timeDimensions?.length ?? 0) > 0)
|
|
283
|
+
roles.add('time_dimension');
|
|
284
|
+
if ((candidate.relationshipEvidence?.length ?? 0) > 0 || /\b(?:relationship|join|bridge)\b/.test(identity))
|
|
285
|
+
roles.add('relationship');
|
|
286
|
+
if (candidate.kind === 'semantic_member' || candidate.semanticObjectType === 'dimension')
|
|
287
|
+
roles.add('categorical_dimension');
|
|
288
|
+
if (candidate.kind === 'sql_column' || candidate.kind === 'dbt_model' || candidate.kind === 'sql_table')
|
|
289
|
+
roles.add('context');
|
|
290
|
+
if (roles.size === 0)
|
|
291
|
+
roles.add('context');
|
|
292
|
+
return [...roles];
|
|
293
|
+
}
|
|
294
|
+
function candidateMatchesTerms(candidate, terms, options = {}) {
|
|
295
|
+
if (terms.length === 0)
|
|
296
|
+
return false;
|
|
297
|
+
const identity = uniqueRequirementTerms([
|
|
298
|
+
candidate.id,
|
|
299
|
+
candidate.qualifiedId,
|
|
300
|
+
candidate.name,
|
|
301
|
+
...(candidate.aliases ?? []),
|
|
302
|
+
...(candidate.dimensions ?? []),
|
|
303
|
+
]).join(' ');
|
|
304
|
+
if (terms.some((term) => identity.includes(term) || term.includes(identity)))
|
|
305
|
+
return true;
|
|
306
|
+
return options.categoricalDimension === true
|
|
307
|
+
&& candidateMatchesCategoricalDimensionRequirement(candidate, terms);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* A categorical dimension may satisfy a requested business role only through
|
|
311
|
+
* its own snapshot-authored declaration. In particular, `location_name` is
|
|
312
|
+
* not a synonym for `region`: it can fill a region lane only when metadata
|
|
313
|
+
* explicitly says `alternative-for:region`, or when the dimension itself is
|
|
314
|
+
* declared with the semantic geography role. This protects admission from
|
|
315
|
+
* broad lexical geography expansion while retaining role-balanced recall.
|
|
316
|
+
*/
|
|
317
|
+
export function candidateMatchesCategoricalDimensionRequirement(candidate, terms) {
|
|
318
|
+
const facts = new Set((candidate.compatibilityFacts ?? [])
|
|
319
|
+
.map(normalizeRequirementTerm)
|
|
320
|
+
.filter(Boolean));
|
|
321
|
+
if (facts.size === 0)
|
|
322
|
+
return false;
|
|
323
|
+
const requestedRoles = [...new Set(terms.flatMap((term) => {
|
|
324
|
+
const normalized = normalizeRequirementTerm(term);
|
|
325
|
+
const terminal = normalized.split(' ').at(-1) ?? '';
|
|
326
|
+
return [normalized, terminal].filter(Boolean);
|
|
327
|
+
}))];
|
|
328
|
+
const hasDeclaredAlternative = requestedRoles.some((role) => facts.has(`alternative for ${role}`)
|
|
329
|
+
|| facts.has(`dimension alternative for ${role}`));
|
|
330
|
+
if (hasDeclaredAlternative)
|
|
331
|
+
return true;
|
|
332
|
+
const declaredGeography = facts.has('semantic role geography')
|
|
333
|
+
|| facts.has('semantic geography role');
|
|
334
|
+
return declaredGeography && requestedRoles.some((role) => role === 'region' || role === 'geography' || role === 'geographic');
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Keep an internal retrieval result broad while making the provider package
|
|
338
|
+
* role-balanced. Exact/alias matches stay pinned; each requested role gets up
|
|
339
|
+
* to two candidates before relevance fills remaining cards.
|
|
340
|
+
*/
|
|
341
|
+
export function selectRoleBalancedMeaningCandidates(input) {
|
|
342
|
+
const max = Math.max(1, Math.min(16, Math.floor(input.maxCandidates ?? 16)));
|
|
343
|
+
const ranked = [...new Map(input.candidates
|
|
344
|
+
.filter((candidate) => candidate.id.trim() && candidate.compatibility !== 'incompatible')
|
|
345
|
+
.map((candidate) => [candidate.id, candidate])).values()]
|
|
346
|
+
.sort((left, right) => Number(Boolean(right.exactMatch)) - Number(Boolean(left.exactMatch))
|
|
347
|
+
|| (right.relevanceScore ?? 0) - (left.relevanceScore ?? 0)
|
|
348
|
+
|| left.id.localeCompare(right.id));
|
|
349
|
+
const selected = [];
|
|
350
|
+
const add = (candidate) => {
|
|
351
|
+
if (candidate && selected.length < max && !selected.some((item) => item.id === candidate.id))
|
|
352
|
+
selected.push(candidate);
|
|
353
|
+
};
|
|
354
|
+
const servesRequestedRole = (candidate) => {
|
|
355
|
+
const roles = evidenceCandidateRoles(candidate);
|
|
356
|
+
const metricTerms = input.requirements.ranking?.metricTerms.length
|
|
357
|
+
? input.requirements.ranking.metricTerms
|
|
358
|
+
: input.requirements.measures;
|
|
359
|
+
if (roles.includes('metric') && candidateMatchesTerms(candidate, metricTerms))
|
|
360
|
+
return true;
|
|
361
|
+
// An entity term such as "account" is deliberately insufficient for an
|
|
362
|
+
// attribute (Account Owner Email) to displace the requested display key.
|
|
363
|
+
// Only an actual entity-label candidate may satisfy this binding.
|
|
364
|
+
if (roles.includes('entity_label') && candidateMatchesTerms(candidate, [
|
|
365
|
+
...input.requirements.entityTerms,
|
|
366
|
+
...input.requirements.entityDisplayTerms,
|
|
367
|
+
]))
|
|
368
|
+
return true;
|
|
369
|
+
if (roles.includes('time_dimension') && Boolean(input.requirements.time))
|
|
370
|
+
return true;
|
|
371
|
+
if (roles.includes('categorical_dimension')
|
|
372
|
+
&& input.requirements.dimensions.length > 0
|
|
373
|
+
&& candidateMatchesTerms(candidate, input.requirements.dimensions, { categoricalDimension: true }))
|
|
374
|
+
return true;
|
|
375
|
+
if (roles.includes('relationship')
|
|
376
|
+
&& (input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0))
|
|
377
|
+
return true;
|
|
378
|
+
return false;
|
|
379
|
+
};
|
|
380
|
+
for (const candidate of ranked.filter((candidate) => candidate.exactMatch)) {
|
|
381
|
+
// In a pin-only prepass, an exact match is only a pin when it serves a
|
|
382
|
+
// requested analytical role. Otherwise a pile of exact members consumes
|
|
383
|
+
// the whole package before the requested metric/entity can be reserved.
|
|
384
|
+
if (input.pinOnly && !servesRequestedRole(candidate))
|
|
385
|
+
continue;
|
|
386
|
+
add(candidate);
|
|
387
|
+
}
|
|
388
|
+
const required = [
|
|
389
|
+
['metric', input.requirements.ranking?.metricTerms.length ? input.requirements.ranking.metricTerms : input.requirements.measures],
|
|
390
|
+
['entity_label', [...input.requirements.entityTerms, ...input.requirements.entityDisplayTerms]],
|
|
391
|
+
['time_dimension', input.requirements.time ? [input.requirements.time.grain ?? 'time'] : []],
|
|
392
|
+
['categorical_dimension', input.requirements.dimensions],
|
|
393
|
+
['relationship', input.requirements.dimensions.length > 1 || input.requirements.entityTerms.length > 0 ? ['relationship'] : []],
|
|
394
|
+
];
|
|
395
|
+
for (const [role, terms] of required) {
|
|
396
|
+
// No requested categorical dimension means that high-scoring arbitrary
|
|
397
|
+
// members are noise, not a role reservation. This is the subtle path that
|
|
398
|
+
// used to admit Account Owner and Sentiment immediately after Account Name.
|
|
399
|
+
if (terms.length === 0)
|
|
400
|
+
continue;
|
|
401
|
+
let admitted = 0;
|
|
402
|
+
for (const candidate of ranked) {
|
|
403
|
+
if (admitted >= 2 || selected.length >= max)
|
|
404
|
+
break;
|
|
405
|
+
const roles = evidenceCandidateRoles(candidate);
|
|
406
|
+
if (!roles.includes(role))
|
|
407
|
+
continue;
|
|
408
|
+
// "top accounts" needs the account display key, not any field whose
|
|
409
|
+
// label happens to contain account. Once a display candidate is
|
|
410
|
+
// available, owner/e-mail/sentiment attributes are neither the entity
|
|
411
|
+
// role nor a useful categorical reservation unless the user explicitly
|
|
412
|
+
// named that attribute. This runs during the pre-cap pin pass so noisy
|
|
413
|
+
// same-kind cards cannot enter through the categorical role.
|
|
414
|
+
const explicitlyRequestsAttribute = /\b(?:owner|sentiment|email)\b/i.test([
|
|
415
|
+
...input.requirements.dimensions,
|
|
416
|
+
...input.requirements.entityTerms,
|
|
417
|
+
...input.requirements.entityDisplayTerms,
|
|
418
|
+
].join(' '));
|
|
419
|
+
const hasRequestedEntityLabel = ranked.some((item) => evidenceCandidateRoles(item).includes('entity_label')
|
|
420
|
+
&& candidateMatchesTerms(item, [
|
|
421
|
+
...input.requirements.entityTerms,
|
|
422
|
+
...input.requirements.entityDisplayTerms,
|
|
423
|
+
]));
|
|
424
|
+
if (role === 'categorical_dimension'
|
|
425
|
+
&& hasRequestedEntityLabel
|
|
426
|
+
&& !explicitlyRequestsAttribute
|
|
427
|
+
&& /\b(?:owner|sentiment|email)\b/i.test(candidate.name ?? candidate.id))
|
|
428
|
+
continue;
|
|
429
|
+
// For entity labels, role is more important than a lexical owner/email
|
|
430
|
+
// hit. For all other roles, prefer an identity matching the requested
|
|
431
|
+
// business term but retain a role candidate when the request is terse.
|
|
432
|
+
if (terms.length > 0 && !candidateMatchesTerms(candidate, terms, { categoricalDimension: role === 'categorical_dimension' })
|
|
433
|
+
&& role !== 'time_dimension' && role !== 'relationship' && role !== 'entity_label')
|
|
434
|
+
continue;
|
|
435
|
+
add(candidate);
|
|
436
|
+
admitted += 1;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (!input.pinOnly) {
|
|
440
|
+
for (const candidate of ranked)
|
|
441
|
+
add(candidate);
|
|
442
|
+
}
|
|
443
|
+
return selected;
|
|
444
|
+
}
|
|
445
|
+
export function classifyProviderFailure(input) {
|
|
446
|
+
const text = `${input.code ?? ''} ${input.message ?? ''}`.toLowerCase();
|
|
447
|
+
const cause = /cancel/.test(text) ? 'cancelled'
|
|
448
|
+
: /dispatch.?budget|provider_dispatch_budget/.test(text) ? 'dispatch_budget'
|
|
449
|
+
: /deadline.?insufficient|admission|soft.?target/.test(text) ? 'admission_denied'
|
|
450
|
+
: /run.?deadline|time limit/.test(text) ? 'run_deadline'
|
|
451
|
+
: /timeout|timed out/.test(text) ? 'provider_timeout'
|
|
452
|
+
: /401|403|api key|unauthori[sz]ed|auth(?:entication)?/.test(text) ? 'authentication'
|
|
453
|
+
: /model(?:[ _-]+|\s+).*not[ _-]?found|unknown model|model_not_found|404/.test(text) ? 'model_not_found'
|
|
454
|
+
: /429|rate[ _-]?limit|too many requests/.test(text) ? 'rate_limited'
|
|
455
|
+
: /502|503|504|gateway/.test(text) ? 'gateway'
|
|
456
|
+
: /econn|network|fetch failed|not reachable|connection refused/.test(text) ? 'network'
|
|
457
|
+
: 'unknown';
|
|
458
|
+
const retryable = cause === 'rate_limited' || cause === 'gateway' || cause === 'network' || cause === 'provider_timeout';
|
|
459
|
+
const safeAction = retryable ? (cause === 'rate_limited' ? 'wait_and_retry' : 'retry_same_provider')
|
|
460
|
+
: cause === 'authentication' || cause === 'model_not_found' ? 'fix_provider_configuration'
|
|
461
|
+
: cause === 'cancelled' ? 'none'
|
|
462
|
+
: 'inspect_run';
|
|
463
|
+
const httpStatusClass = /\b(?:401|403|404|429)\b/.test(text) ? '4xx'
|
|
464
|
+
: /\b(?:502|503|504)\b/.test(text) ? '5xx'
|
|
465
|
+
: undefined;
|
|
466
|
+
return {
|
|
467
|
+
version: 1,
|
|
468
|
+
cause,
|
|
469
|
+
phase: input.phase ?? 'unknown',
|
|
470
|
+
retryable,
|
|
471
|
+
safeAction,
|
|
472
|
+
...(httpStatusClass ? { httpStatusClass } : {}),
|
|
473
|
+
...(input.providerFingerprint ? { providerFingerprint: input.providerFingerprint } : {}),
|
|
474
|
+
...(input.modelFingerprint ? { modelFingerprint: input.modelFingerprint } : {}),
|
|
475
|
+
...(input.baseOriginFingerprint ? { baseOriginFingerprint: input.baseOriginFingerprint } : {}),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
export function buildAnalyticalCascadeDecision(input) {
|
|
479
|
+
return {
|
|
480
|
+
version: 1,
|
|
481
|
+
...input,
|
|
482
|
+
sourceCoverage: input.sourceCoverage.map((coverage) => ({
|
|
483
|
+
...coverage,
|
|
484
|
+
version: 1,
|
|
485
|
+
candidateIds: [...new Set(coverage.candidateIds)].slice(0, 32),
|
|
486
|
+
})),
|
|
487
|
+
attempts: input.attempts.map((attempt) => ({ ...attempt, version: 1, candidateIds: [...new Set(attempt.candidateIds)].slice(0, 32) })),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* `Regarding: "Mr. Matthew Meyer"` and friends — a short label, a colon, and a
|
|
492
|
+
* quoted value, with nothing else in the clause. Deliberately narrow: it must
|
|
493
|
+
* be the WHOLE clause and the value must be quoted, so an ordinary question
|
|
494
|
+
* that happens to contain a colon is untouched.
|
|
495
|
+
*/
|
|
496
|
+
const ANNOTATION_CLAUSE = /^\s*[A-Za-z][A-Za-z ]{0,24}:\s*["“'‘][^"”'’]*["”'’]\s*$/;
|
|
44
497
|
export function splitAnalyticalTasks(question) {
|
|
45
498
|
// The separator belongs to the SPLIT, not to the clause. Carrying it through
|
|
46
499
|
// produced a child task whose question was literally
|
|
@@ -49,22 +502,36 @@ export function splitAnalyticalTasks(question) {
|
|
|
49
502
|
// the task title.
|
|
50
503
|
const leadingJunk = /^(?:[\s"'“”‘’,:;.\-]+|\b(?:then|and|also)\b)+/i;
|
|
51
504
|
const trailingJunk = /[\s"'“”‘’,:;.\-]+$/;
|
|
52
|
-
const
|
|
505
|
+
const raw = question
|
|
53
506
|
.split(/\s*(?:\?|;|\band then\b|\balso\b)\s*/i)
|
|
54
|
-
.flatMap((part) => part.split(/\s+\band\s+(?=(?:what|who|which|show|list|tell|give|how|why)\b)/i))
|
|
507
|
+
.flatMap((part) => part.split(/\s+\band\s+(?=(?:what|who|which|show|list|tell|give|how|why)\b)/i));
|
|
508
|
+
// A `Label: "value"` clause is an ANNOTATION, not a question. The composer
|
|
509
|
+
// appends `Regarding: "<selected row>"` when the reader follows up on
|
|
510
|
+
// something they clicked, and splitting on the `?` before it turned that
|
|
511
|
+
// referent into a task of its own — titled `Regarding: "Mr. Matthew Meyer`,
|
|
512
|
+
// which then "answered" by computing an unrelated maximum over the rows still
|
|
513
|
+
// on screen. Context says WHO the real task is about; it is never a task.
|
|
514
|
+
// Filtered before the junk strip, which would unbalance the quotes.
|
|
515
|
+
const asked = raw.filter((part) => !ANNOTATION_CLAUSE.test(part));
|
|
516
|
+
const parts = (asked.length > 0 ? asked : raw)
|
|
55
517
|
.map((part) => part.replace(leadingJunk, '').replace(trailingJunk, '').trim())
|
|
56
518
|
.filter(Boolean);
|
|
57
519
|
return parts.length > 0 ? parts : [question.trim()];
|
|
58
520
|
}
|
|
59
521
|
export function buildAnalyticalTaskGraph(input) {
|
|
60
|
-
const
|
|
522
|
+
const rootKind = input.mode === 'research' ? 'research' : inferAnalyticalTurnKind(input.question);
|
|
523
|
+
// A research turn may later create bounded evidence branches, but that is a
|
|
524
|
+
// research planner's job. Splitting at ingress loses the surrounding story
|
|
525
|
+
// before it has an opportunity to reason about it.
|
|
526
|
+
const clauses = (rootKind === 'research' ? [input.question.trim()] : splitAnalyticalTasks(input.question))
|
|
527
|
+
.slice(0, Math.max(1, Math.min(6, input.maxTasks ?? 6)));
|
|
61
528
|
const candidateIds = [...new Set((input.candidateIds ?? []).filter((id) => id.trim()))];
|
|
62
529
|
const metrics = [...new Set((input.metrics ?? []).filter((metric) => metric.trim()))];
|
|
63
530
|
const dimensions = [...new Set((input.dimensions ?? []).filter((dimension) => dimension.trim()))];
|
|
64
531
|
const filters = input.filters ?? [];
|
|
65
532
|
const inheritedBindings = input.inheritedBindings ?? [];
|
|
66
|
-
const
|
|
67
|
-
const kind = inferAnalyticalTurnKind(clause);
|
|
533
|
+
const unboundTasks = clauses.map((clause, index) => {
|
|
534
|
+
const kind = rootKind === 'research' ? 'research' : inferAnalyticalTurnKind(clause);
|
|
68
535
|
const research = kind === 'research' || kind === 'diagnosis';
|
|
69
536
|
const taskKind = research
|
|
70
537
|
? 'research_branch'
|
|
@@ -96,12 +563,36 @@ export function buildAnalyticalTaskGraph(input) {
|
|
|
96
563
|
inheritedBindings,
|
|
97
564
|
};
|
|
98
565
|
});
|
|
566
|
+
const tasks = bindTopRankedRegionDependencies(unboundTasks);
|
|
99
567
|
return {
|
|
100
|
-
kind: tasks.length > 1 ? 'compound' :
|
|
568
|
+
kind: rootKind === 'research' ? 'research' : tasks.length > 1 ? 'compound' : rootKind,
|
|
101
569
|
tasks,
|
|
102
570
|
partial: false,
|
|
103
571
|
};
|
|
104
572
|
}
|
|
573
|
+
const TOP_RANKED_REGION_RE = /(?:\b(?:top|highest|most)\b[^?.!]{0,72}\bregions?\b|\bregions?\b[^?.!]{0,72}\b(?:top|highest|most)\b)/i;
|
|
574
|
+
const DEPENDENT_REGION_CUSTOMERS_RE = /\b(?:that|this|same)\s+region\b/i;
|
|
575
|
+
/**
|
|
576
|
+
* Recognise only the demonstrated two-step dependency. Broader pronoun
|
|
577
|
+
* resolution belongs to the normal conversation layer; turning every compound
|
|
578
|
+
* question into a dependency would serialise unrelated work and invent filters.
|
|
579
|
+
*/
|
|
580
|
+
function bindTopRankedRegionDependencies(tasks) {
|
|
581
|
+
return tasks.map((task, index) => {
|
|
582
|
+
if (!DEPENDENT_REGION_CUSTOMERS_RE.test(task.question) || !/\bcustomers?\b/i.test(task.question))
|
|
583
|
+
return task;
|
|
584
|
+
const parent = tasks.slice(0, index).reverse().find((candidate) => TOP_RANKED_REGION_RE.test(candidate.question));
|
|
585
|
+
if (!parent)
|
|
586
|
+
return task;
|
|
587
|
+
const dependency = {
|
|
588
|
+
version: 1,
|
|
589
|
+
kind: 'top_ranked_region',
|
|
590
|
+
sourceTaskId: parent.id,
|
|
591
|
+
targetDimension: 'region',
|
|
592
|
+
};
|
|
593
|
+
return { ...task, dependencies: [parent.id], dependency };
|
|
594
|
+
});
|
|
595
|
+
}
|
|
105
596
|
export function buildAnalyticalTurnPlan(input) {
|
|
106
597
|
const graph = buildAnalyticalTaskGraph(input);
|
|
107
598
|
const zeroCall = input.zeroCallReason === 'explicit_binding'
|
|
@@ -134,6 +625,173 @@ export function summarizeTaskOutcomes(tasks) {
|
|
|
134
625
|
: 'gap';
|
|
135
626
|
return { status, completed, gaps };
|
|
136
627
|
}
|
|
628
|
+
/** Stable scalar transport for a selected canonical result cell. */
|
|
629
|
+
export function canonicalResultBindingValue(value) {
|
|
630
|
+
if (typeof value === 'string')
|
|
631
|
+
return value;
|
|
632
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
633
|
+
return String(value);
|
|
634
|
+
if (typeof value === 'boolean')
|
|
635
|
+
return String(value);
|
|
636
|
+
return undefined;
|
|
637
|
+
}
|
|
638
|
+
/** A row proof includes all canonical columns, not just the selected cell. */
|
|
639
|
+
export function canonicalResultRowFingerprint(result, row) {
|
|
640
|
+
return stableFingerprint({
|
|
641
|
+
columns: result.columns,
|
|
642
|
+
row: Object.fromEntries(result.columns.map((column) => [column, row[column]])),
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Validate an untrusted selected-result reference against a persisted
|
|
647
|
+
* canonical result. The source run/artifact identities are checked by the
|
|
648
|
+
* host before calling this pure result-level validator.
|
|
649
|
+
*/
|
|
650
|
+
export function validateSelectedResultBinding(binding, result) {
|
|
651
|
+
if (!binding
|
|
652
|
+
|| binding.version !== 1
|
|
653
|
+
|| !binding.sourceRunId.trim()
|
|
654
|
+
|| !binding.sourceArtifactId.trim()
|
|
655
|
+
|| !binding.canonicalColumn.trim()
|
|
656
|
+
|| !binding.value.trim()
|
|
657
|
+
|| !normalizeAnalyticalExecutionFingerprint(binding.rowFingerprint)
|
|
658
|
+
|| !normalizeAnalyticalExecutionFingerprint(binding.resultFingerprint)) {
|
|
659
|
+
return {
|
|
660
|
+
ok: false,
|
|
661
|
+
code: 'RESULT_BINDING_INVALID',
|
|
662
|
+
message: 'The selected result reference is incomplete or malformed.',
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
if (!result || binding.resultFingerprint !== result.resultFingerprint) {
|
|
666
|
+
return {
|
|
667
|
+
ok: false,
|
|
668
|
+
code: 'RESULT_BINDING_RESULT_MISMATCH',
|
|
669
|
+
message: 'The selected result is no longer the persisted result for this run.',
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
if (!result.columns.includes(binding.canonicalColumn)) {
|
|
673
|
+
return {
|
|
674
|
+
ok: false,
|
|
675
|
+
code: 'RESULT_BINDING_ROW_MISMATCH',
|
|
676
|
+
message: `The selected column ${binding.canonicalColumn} is not present in the persisted result.`,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
const matched = result.rows.some((row) => canonicalResultRowFingerprint(result, row) === binding.rowFingerprint
|
|
680
|
+
&& canonicalResultBindingValue(row[binding.canonicalColumn]) === binding.value);
|
|
681
|
+
if (!matched) {
|
|
682
|
+
return {
|
|
683
|
+
ok: false,
|
|
684
|
+
code: 'RESULT_BINDING_ROW_MISMATCH',
|
|
685
|
+
message: 'The selected row/value does not match the persisted result.',
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
return { ok: true, binding: { ...binding, resultFingerprint: result.resultFingerprint } };
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Derive the one value a `top region -> customers in that region` child may
|
|
692
|
+
* consume. A first row alone is never proof. The parent must either return an
|
|
693
|
+
* explicit rank, or be the server-computed descending ranking task and return
|
|
694
|
+
* at least two rows with one strictly-leading numeric measure. `rowCount` is
|
|
695
|
+
* the returned-row contract, not proof that a singleton is the complete group
|
|
696
|
+
* set. Ties and ambiguous result shapes remain a typed child gap.
|
|
697
|
+
*/
|
|
698
|
+
export function resolveTopRankedRegionDependency(sourceTaskId, result, parentTask) {
|
|
699
|
+
if (!result || result.rows.length === 0) {
|
|
700
|
+
return {
|
|
701
|
+
ok: false,
|
|
702
|
+
code: 'RESULT_CONTRACT_MISMATCH',
|
|
703
|
+
message: 'The top-region task did not produce a canonical result that can bind the dependent customer task.',
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
const canonicalResultFingerprint = normalizeAnalyticalExecutionFingerprint(result.resultFingerprint);
|
|
707
|
+
const executionReceipt = normalizeAnalyticalExecutionReceipt(result.executionReceipt);
|
|
708
|
+
if (!executionReceipt) {
|
|
709
|
+
return {
|
|
710
|
+
ok: false,
|
|
711
|
+
code: 'RESULT_CONTRACT_MISMATCH',
|
|
712
|
+
message: 'The top-region result did not retain a complete normalized execution receipt, so the dependent customer task was not run.',
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
if (!canonicalResultFingerprint
|
|
716
|
+
|| result.resultFingerprint !== canonicalResultFingerprint
|
|
717
|
+
|| executionReceipt.resultFingerprint !== canonicalResultFingerprint) {
|
|
718
|
+
return {
|
|
719
|
+
ok: false,
|
|
720
|
+
code: 'RESULT_CONTRACT_MISMATCH',
|
|
721
|
+
message: 'The top-region execution receipt does not match the canonical result, so the dependent customer task was not run.',
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
const regionColumns = result.columns.filter((column) => /(?:^|_)region(?:_name)?$/i.test(column));
|
|
725
|
+
if (regionColumns.length !== 1) {
|
|
726
|
+
return {
|
|
727
|
+
ok: false,
|
|
728
|
+
code: 'RESULT_CONTRACT_MISMATCH',
|
|
729
|
+
message: 'The top-region result did not contain exactly one canonical region column.',
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
const canonicalColumn = regionColumns[0];
|
|
733
|
+
const first = result.rows[0];
|
|
734
|
+
const value = canonicalResultBindingValue(first[canonicalColumn]);
|
|
735
|
+
if (!value) {
|
|
736
|
+
return {
|
|
737
|
+
ok: false,
|
|
738
|
+
code: 'RESULT_CONTRACT_MISMATCH',
|
|
739
|
+
message: 'The leading top-region row did not contain a usable region value.',
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
if (!hasUnambiguousTopRow(result, parentTask)) {
|
|
743
|
+
return {
|
|
744
|
+
ok: false,
|
|
745
|
+
code: 'RESULT_CONTRACT_MISMATCH',
|
|
746
|
+
message: 'The top-region result did not prove a single leading region, so the dependent customer task was not run.',
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
return {
|
|
750
|
+
ok: true,
|
|
751
|
+
binding: {
|
|
752
|
+
version: 1,
|
|
753
|
+
sourceTaskId,
|
|
754
|
+
sourceResultFingerprint: canonicalResultFingerprint,
|
|
755
|
+
canonicalColumn,
|
|
756
|
+
value,
|
|
757
|
+
rowFingerprint: canonicalResultRowFingerprint(result, first),
|
|
758
|
+
},
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
function hasUnambiguousTopRow(result, parentTask) {
|
|
762
|
+
const rankColumn = result.columns.find((column) => /(?:^|_)(?:rank|row_number)$/i.test(column));
|
|
763
|
+
if (rankColumn) {
|
|
764
|
+
const ranks = result.rows.map((row) => numericCell(row[rankColumn]));
|
|
765
|
+
if (ranks[0] === 1 && ranks.slice(1).every((rank) => rank === undefined || rank > 1))
|
|
766
|
+
return true;
|
|
767
|
+
}
|
|
768
|
+
// A plain numerical result column does not say that its first row was ranked.
|
|
769
|
+
// It becomes a ranking proof only when the server's own parent task explicitly
|
|
770
|
+
// asked for a descending ranking AND the returned rows demonstrate a strictly
|
|
771
|
+
// leading value. A singleton needs an explicit rank = 1: it cannot establish
|
|
772
|
+
// that unreturned groups are lower.
|
|
773
|
+
if (parentTask?.kind !== 'ranking' || parentTask.output.order !== 'desc')
|
|
774
|
+
return false;
|
|
775
|
+
if (result.rows.length < 2)
|
|
776
|
+
return false;
|
|
777
|
+
const measures = result.columns.filter((column) => {
|
|
778
|
+
if (column === rankColumn)
|
|
779
|
+
return false;
|
|
780
|
+
const values = result.rows.map((row) => numericCell(row[column]));
|
|
781
|
+
return values.every((value) => value !== undefined);
|
|
782
|
+
});
|
|
783
|
+
if (measures.length !== 1)
|
|
784
|
+
return false;
|
|
785
|
+
const values = result.rows.map((row) => numericCell(row[measures[0]]));
|
|
786
|
+
return values.slice(1).every((value) => values[0] > value);
|
|
787
|
+
}
|
|
788
|
+
function numericCell(value) {
|
|
789
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
790
|
+
return value;
|
|
791
|
+
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value)))
|
|
792
|
+
return Number(value);
|
|
793
|
+
return undefined;
|
|
794
|
+
}
|
|
137
795
|
/**
|
|
138
796
|
* Normalize connector, MetricFlow, and provider result shapes at one boundary.
|
|
139
797
|
* Connectors may return `ColumnMeta[]` and rows as arrays; UI surfaces should
|
|
@@ -348,6 +1006,124 @@ export function buildResearchEvidenceLedger(input) {
|
|
|
348
1006
|
stoppingReason: input.stoppingReason ?? (entries.length > 0 ? 'completed' : 'not_started'),
|
|
349
1007
|
};
|
|
350
1008
|
}
|
|
1009
|
+
export function buildResearchEvidenceLedgerV2(input) {
|
|
1010
|
+
const v1 = buildResearchEvidenceLedger(input);
|
|
1011
|
+
const entries = v1.entries.map((entry, index) => {
|
|
1012
|
+
const source = input.entries[index];
|
|
1013
|
+
const validator = normalizeResearchEvidenceValidator(source?.validator, entry);
|
|
1014
|
+
const requestedVerdict = /\b(?:because|caused?|driven by|due to)\b/i.test(source?.hypothesis ?? '')
|
|
1015
|
+
? undefined
|
|
1016
|
+
: source?.verdict;
|
|
1017
|
+
const verdict = researchVerdictFromValidatedObservation({
|
|
1018
|
+
status: entry.status,
|
|
1019
|
+
requestedVerdict,
|
|
1020
|
+
validator,
|
|
1021
|
+
});
|
|
1022
|
+
const validFactIds = new Set(entry.facts);
|
|
1023
|
+
const counterEvidenceFactIds = [...new Set(source?.counterEvidenceFactIds ?? [])]
|
|
1024
|
+
.filter((factId) => validFactIds.has(factId));
|
|
1025
|
+
return {
|
|
1026
|
+
...entry,
|
|
1027
|
+
verdict,
|
|
1028
|
+
...(source?.hypothesis?.trim() ? { hypothesis: source.hypothesis.trim() } : {}),
|
|
1029
|
+
...(validator ? { validator } : {}),
|
|
1030
|
+
counterEvidenceFactIds,
|
|
1031
|
+
};
|
|
1032
|
+
});
|
|
1033
|
+
const observedGroundableBranchCount = entries.filter((entry) => entry.status === 'observed' && entry.verdict !== 'failed' && entry.verdict !== 'skipped').length;
|
|
1034
|
+
const plannedGroundableBranchCount = Math.max(0, Math.min(6, Math.trunc(input.groundableBranchCount ?? 0)));
|
|
1035
|
+
const groundableBranchCount = Math.max(observedGroundableBranchCount, plannedGroundableBranchCount);
|
|
1036
|
+
return {
|
|
1037
|
+
version: 2,
|
|
1038
|
+
rootQuestion: v1.rootQuestion,
|
|
1039
|
+
...(v1.planId ? { planId: v1.planId } : {}),
|
|
1040
|
+
...(v1.snapshotId ? { snapshotId: v1.snapshotId } : {}),
|
|
1041
|
+
entries,
|
|
1042
|
+
factIds: [...new Set(entries.flatMap((entry) => [...entry.facts, ...entry.counterEvidenceFactIds]))],
|
|
1043
|
+
groundableBranchCount,
|
|
1044
|
+
limitedScope: groundableBranchCount < 3,
|
|
1045
|
+
stoppingReason: v1.stoppingReason,
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Normalise a hypothesis plan into the bounded research contract. The caller
|
|
1050
|
+
* may supply fewer than three grounded hypotheses; that is retained honestly as
|
|
1051
|
+
* limited scope rather than padded with invented joins or explanations.
|
|
1052
|
+
*/
|
|
1053
|
+
export function buildResearchHypothesisPlanV2(input) {
|
|
1054
|
+
const seen = new Set();
|
|
1055
|
+
const hypotheses = [];
|
|
1056
|
+
for (const candidate of input.hypotheses) {
|
|
1057
|
+
const statement = candidate.statement.trim();
|
|
1058
|
+
const expectation = candidate.expectation.trim();
|
|
1059
|
+
const targetId = candidate.targetId.trim();
|
|
1060
|
+
if (!statement || !expectation || !targetId)
|
|
1061
|
+
continue;
|
|
1062
|
+
const key = `${statement.toLowerCase()}\u0000${targetId.toLowerCase()}`;
|
|
1063
|
+
if (seen.has(key))
|
|
1064
|
+
continue;
|
|
1065
|
+
seen.add(key);
|
|
1066
|
+
hypotheses.push({
|
|
1067
|
+
id: candidate.id?.trim() || `hypothesis:${hypotheses.length + 1}`,
|
|
1068
|
+
statement,
|
|
1069
|
+
expectation,
|
|
1070
|
+
targetId,
|
|
1071
|
+
validatorKind: candidate.validatorKind ?? inferResearchValidatorKind(statement, expectation),
|
|
1072
|
+
});
|
|
1073
|
+
if (hypotheses.length >= 6)
|
|
1074
|
+
break;
|
|
1075
|
+
}
|
|
1076
|
+
return { version: 2, hypotheses, limitedScope: hypotheses.length < 3 };
|
|
1077
|
+
}
|
|
1078
|
+
/** Map an action/expectation to a deterministic observation class only. */
|
|
1079
|
+
export function inferResearchValidatorKind(statement, expectation = '') {
|
|
1080
|
+
const text = `${statement} ${expectation}`.toLowerCase();
|
|
1081
|
+
if (/fresh|updated|stale|as of|recency/.test(text))
|
|
1082
|
+
return 'freshness';
|
|
1083
|
+
if (/contribut|driver|segment|breakdown|dominant/.test(text))
|
|
1084
|
+
return 'contributor';
|
|
1085
|
+
if (/trend|time|month|week|quarter|year|shift|change/.test(text))
|
|
1086
|
+
return 'trend';
|
|
1087
|
+
if (/compare|versus|vs\.?|difference/.test(text))
|
|
1088
|
+
return 'comparison';
|
|
1089
|
+
if (/anomal|outlier|spike|drop/.test(text))
|
|
1090
|
+
return 'anomaly';
|
|
1091
|
+
return 'counter_evidence';
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* A verdict is promoted only from a validator that evaluated a deterministic
|
|
1095
|
+
* observation against a branch receipt. Rows by themselves stay inconclusive;
|
|
1096
|
+
* causal statements are never supported by this helper.
|
|
1097
|
+
*/
|
|
1098
|
+
export function researchVerdictFromValidatedObservation(input) {
|
|
1099
|
+
if (input.status === 'failed')
|
|
1100
|
+
return 'failed';
|
|
1101
|
+
if (input.status === 'skipped')
|
|
1102
|
+
return 'skipped';
|
|
1103
|
+
if (!input.validator?.evaluated || input.validator.receiptFingerprints.length === 0)
|
|
1104
|
+
return 'inconclusive';
|
|
1105
|
+
if (input.requestedVerdict === 'supported' && input.validator.outcome === 'supports_observation')
|
|
1106
|
+
return 'supported';
|
|
1107
|
+
if (input.requestedVerdict === 'contradicted' && input.validator.outcome === 'contradicts_observation')
|
|
1108
|
+
return 'contradicted';
|
|
1109
|
+
return 'inconclusive';
|
|
1110
|
+
}
|
|
1111
|
+
function normalizeResearchEvidenceValidator(validator, entry) {
|
|
1112
|
+
if (!validator || validator.version !== 1)
|
|
1113
|
+
return undefined;
|
|
1114
|
+
const knownReceipt = entry.resultFingerprint;
|
|
1115
|
+
const receiptFingerprints = [...new Set(validator.receiptFingerprints)]
|
|
1116
|
+
.map(normalizeAnalyticalExecutionFingerprint)
|
|
1117
|
+
.filter((fingerprint) => Boolean(fingerprint))
|
|
1118
|
+
.filter((fingerprint) => !knownReceipt || fingerprint === knownReceipt);
|
|
1119
|
+
return {
|
|
1120
|
+
version: 1,
|
|
1121
|
+
kind: validator.kind,
|
|
1122
|
+
evaluated: validator.evaluated === true && receiptFingerprints.length > 0,
|
|
1123
|
+
...(validator.outcome ? { outcome: validator.outcome } : {}),
|
|
1124
|
+
receiptFingerprints,
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
351
1127
|
/** The only accepted host-side execution identity is a SHA-256 fingerprint. */
|
|
352
1128
|
export function normalizeAnalyticalExecutionFingerprint(value) {
|
|
353
1129
|
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value.trim())
|