@duckcodeailabs/dql-agent 1.14.1 → 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 +9 -1
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +199 -17
- 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/agentic/analyst-loop.d.ts.map +1 -1
- package/dist/agentic/analyst-loop.js +48 -28
- package/dist/agentic/analyst-loop.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 +3 -2
- package/dist/agentic/research-agent.js.map +1 -1
- package/dist/agentic/sql-authorization.d.ts.map +1 -1
- package/dist/agentic/sql-authorization.js +230 -2
- package/dist/agentic/sql-authorization.js.map +1 -1
- 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 +302 -0
- package/dist/analytical-orchestration.d.ts.map +1 -1
- package/dist/analytical-orchestration.js +564 -0
- package/dist/analytical-orchestration.js.map +1 -1
- package/dist/answer-loop.d.ts +48 -0
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +679 -85
- 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/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/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +28 -0
- package/dist/intent-controller.d.ts.map +1 -1
- package/dist/intent-controller.js +30 -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 +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 +57 -11
- 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 +261 -47
- package/dist/metadata/meaning-evidence.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 +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 +4 -3
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +1796 -125
- 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,452 @@ 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
|
+
}
|
|
44
490
|
/**
|
|
45
491
|
* `Regarding: "Mr. Matthew Meyer"` and friends — a short label, a colon, and a
|
|
46
492
|
* quoted value, with nothing else in the clause. Deliberately narrow: it must
|
|
@@ -560,6 +1006,124 @@ export function buildResearchEvidenceLedger(input) {
|
|
|
560
1006
|
stoppingReason: input.stoppingReason ?? (entries.length > 0 ? 'completed' : 'not_started'),
|
|
561
1007
|
};
|
|
562
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
|
+
}
|
|
563
1127
|
/** The only accepted host-side execution identity is a SHA-256 fingerprint. */
|
|
564
1128
|
export function normalizeAnalyticalExecutionFingerprint(value) {
|
|
565
1129
|
return typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value.trim())
|