@duckcodeailabs/dql-agent 1.7.1 → 1.8.0
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 +2 -0
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +67 -19
- 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 +14 -50
- package/dist/agent-run-gates.js.map +1 -1
- package/dist/agent-run-planner.d.ts.map +1 -1
- package/dist/agent-run-planner.js +14 -4
- package/dist/agent-run-planner.js.map +1 -1
- package/dist/agentic/toolset.js +32 -2
- package/dist/agentic/toolset.js.map +1 -1
- package/dist/answer-loop.d.ts +24 -0
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +387 -55
- package/dist/answer-loop.js.map +1 -1
- package/dist/answer-shape.js +14 -1
- package/dist/answer-shape.js.map +1 -1
- package/dist/cascade/budgets.d.ts.map +1 -1
- package/dist/cascade/budgets.js +5 -2
- package/dist/cascade/budgets.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 -3
- package/dist/conversation/session-store.js.map +1 -1
- package/dist/conversation/snapshot.d.ts +2 -0
- package/dist/conversation/snapshot.d.ts.map +1 -1
- package/dist/conversation/snapshot.js +1 -0
- package/dist/conversation/snapshot.js.map +1 -1
- package/dist/conversation/working-state.d.ts +3 -0
- package/dist/conversation/working-state.d.ts.map +1 -1
- package/dist/conversation/working-state.js +26 -0
- package/dist/conversation/working-state.js.map +1 -1
- package/dist/domain-context.d.ts +14 -0
- package/dist/domain-context.d.ts.map +1 -1
- package/dist/domain-context.js +10 -0
- package/dist/domain-context.js.map +1 -1
- package/dist/embeddings/provider.d.ts +3 -1
- package/dist/embeddings/provider.d.ts.map +1 -1
- package/dist/embeddings/provider.js +19 -8
- package/dist/embeddings/provider.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +33 -4
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +15 -0
- package/dist/intent-controller.d.ts.map +1 -1
- package/dist/intent-controller.js +4 -1
- package/dist/intent-controller.js.map +1 -1
- package/dist/meaning-resolution.d.ts +110 -0
- package/dist/meaning-resolution.d.ts.map +1 -0
- package/dist/meaning-resolution.js +155 -0
- package/dist/meaning-resolution.js.map +1 -0
- package/dist/metadata/analysis-planner.d.ts +15 -1
- package/dist/metadata/analysis-planner.d.ts.map +1 -1
- package/dist/metadata/analysis-planner.js +169 -50
- package/dist/metadata/analysis-planner.js.map +1 -1
- package/dist/metadata/block-fit.d.ts.map +1 -1
- package/dist/metadata/block-fit.js +51 -5
- package/dist/metadata/block-fit.js.map +1 -1
- package/dist/metadata/catalog.d.ts +62 -3
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +911 -98
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/grain-gate.js +27 -9
- package/dist/metadata/grain-gate.js.map +1 -1
- package/dist/metadata/meaning-evidence.d.ts +70 -0
- package/dist/metadata/meaning-evidence.d.ts.map +1 -0
- package/dist/metadata/meaning-evidence.js +333 -0
- package/dist/metadata/meaning-evidence.js.map +1 -0
- package/dist/metadata/metric-match.d.ts.map +1 -1
- package/dist/metadata/metric-match.js +5 -2
- package/dist/metadata/metric-match.js.map +1 -1
- package/dist/router.d.ts +16 -4
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +409 -21
- package/dist/router.js.map +1 -1
- package/dist/semantic-bridge/compose.d.ts +12 -0
- package/dist/semantic-bridge/compose.d.ts.map +1 -1
- package/dist/semantic-bridge/compose.js +177 -14
- package/dist/semantic-bridge/compose.js.map +1 -1
- package/dist/semantic-bridge/member-select.d.ts.map +1 -1
- package/dist/semantic-bridge/member-select.js +59 -10
- package/dist/semantic-bridge/member-select.js.map +1 -1
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +35 -33
- package/dist/skills/loader.js.map +1 -1
- package/dist/synthesize.d.ts +2 -0
- package/dist/synthesize.d.ts.map +1 -1
- package/dist/synthesize.js +84 -13
- package/dist/synthesize.js.map +1 -1
- package/package.json +4 -4
|
@@ -15,7 +15,7 @@ const METRIC_WORDS = [
|
|
|
15
15
|
const DIMENSION_WORDS = [
|
|
16
16
|
'account', 'category', 'channel', 'cohort', 'customer', 'department', 'entity', 'geo',
|
|
17
17
|
'location', 'market', 'merchant', 'month', 'person', 'player', 'product', 'profile',
|
|
18
|
-
'region', 'segment', 'sku', 'team', 'type', 'user', 'vendor', 'week', 'year',
|
|
18
|
+
'region', 'segment', 'sku', 'supply', 'team', 'type', 'user', 'vendor', 'week', 'year',
|
|
19
19
|
];
|
|
20
20
|
/**
|
|
21
21
|
* Small, domain-neutral vocabulary clusters used only to broaden metadata
|
|
@@ -44,21 +44,25 @@ const PROFILE_WORDS = [
|
|
|
44
44
|
export function buildAnalysisQuestionPlan(question, followUp) {
|
|
45
45
|
const cleanQuestion = question.replace(/\s+/g, ' ').trim();
|
|
46
46
|
const normalizedQuestion = normalizeSearchText(cleanQuestion);
|
|
47
|
-
|
|
47
|
+
// Stakeholders occasionally paste semantic/dbt identifiers directly. Treat
|
|
48
|
+
// underscores as word boundaries for intent extraction while retaining the
|
|
49
|
+
// original question for exact output identifiers and auditability.
|
|
50
|
+
const languageQuestion = cleanQuestion.replace(/_/g, ' ');
|
|
51
|
+
const lower = languageQuestion.toLowerCase();
|
|
48
52
|
const entities = extractEntities(cleanQuestion);
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
const dimensionTerms = removeFilterOnlyDimensionTerms(cleanQuestion, extractedDimensionTerms);
|
|
56
|
-
const timeTerms = extractTimeTerms(cleanQuestion);
|
|
53
|
+
const valueMentions = extractValueMentions(entities);
|
|
54
|
+
const metricTerms = resolveQuestionMetricTerms(lower, extractMetricTerms(languageQuestion), extractFollowUpMetricTerms(followUp, lower));
|
|
55
|
+
const extractedDimensionTerms = extractDimensionTerms(languageQuestion);
|
|
56
|
+
const filterTerms = extractFilterTerms(languageQuestion, entities);
|
|
57
|
+
const dimensionTerms = removeFilterOnlyDimensionTerms(languageQuestion, extractedDimensionTerms);
|
|
58
|
+
const timeTerms = extractTimeTerms(languageQuestion);
|
|
57
59
|
const mode = inferQuestionMode({ question: cleanQuestion, lower, entities, metricTerms, dimensionTerms, followUp });
|
|
58
60
|
const routeIntent = routeIntentForMode(mode);
|
|
59
61
|
const outputShape = outputShapeForMode(mode, lower, dimensionTerms);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
// Certified capability matching is contract-first. An explicit analytical
|
|
63
|
+
// shape ("lifetime spend by customer") is a reason to reject a definition
|
|
64
|
+
// term, not a reason to skip a fully compatible certified block.
|
|
65
|
+
const shouldConsiderCertifiedExact = certifiedExactIsPlausible(mode, entities);
|
|
62
66
|
const needsGeneratedSql = generatedSqlIsLikely(mode, shouldConsiderCertifiedExact);
|
|
63
67
|
const needsResearchWorkspace = researchWorkspaceIsLikely(mode, lower);
|
|
64
68
|
const requestedShape = buildRequestedAnswerShape(cleanQuestion, {
|
|
@@ -96,6 +100,7 @@ export function buildAnalysisQuestionPlan(question, followUp) {
|
|
|
96
100
|
mode,
|
|
97
101
|
routeIntent,
|
|
98
102
|
entities,
|
|
103
|
+
valueMentions,
|
|
99
104
|
metricTerms,
|
|
100
105
|
dimensionTerms,
|
|
101
106
|
filterTerms,
|
|
@@ -125,7 +130,7 @@ export function certifiedApplicabilityForObject(object, plan) {
|
|
|
125
130
|
const dimensionMatch = scoreTerms(text, plan.dimensionTerms);
|
|
126
131
|
const searchMatch = scoreTerms(text, plan.searchTerms);
|
|
127
132
|
const directionCompatible = rankingDirectionCompatible(plan.question, directionalTextForObject(object));
|
|
128
|
-
const asksDifferentGrain = plan.
|
|
133
|
+
const asksDifferentGrain = plan.entities.length > 0 || plan.mode === 'entity_profile';
|
|
129
134
|
const score = Number(((explicitName ? 45 : 0) +
|
|
130
135
|
(exactExample ? 55 : 0) +
|
|
131
136
|
metricMatch.score * 12 +
|
|
@@ -463,20 +468,19 @@ function isMetricLikeColumn(column) {
|
|
|
463
468
|
}
|
|
464
469
|
function inferQuestionMode(input) {
|
|
465
470
|
const { lower } = input;
|
|
471
|
+
// A relative measure predicate is not an ordinary entity drilldown. It asks for
|
|
472
|
+
// a peer set whose aggregate is compared with a named entity's aggregate (for
|
|
473
|
+
// example, "customers who paid less tax than Melissa"). Classify it before the
|
|
474
|
+
// conversation drilldown carry so a prior certified block cannot turn the ask
|
|
475
|
+
// into a simple equality filter or a single global KPI.
|
|
476
|
+
if (isEntityRelativeMeasureComparison(lower))
|
|
477
|
+
return 'comparison';
|
|
466
478
|
if (followUpKind(input.followUp) === 'drilldown')
|
|
467
479
|
return 'entity_drilldown';
|
|
468
480
|
if (/^\s*(run|execute|open)\b/i.test(lower))
|
|
469
481
|
return 'exact_lookup';
|
|
470
482
|
if (/\b(trust|rely|certif|certified|lineage|owner|caveat|gap|governance|can .* trust)\b/i.test(lower))
|
|
471
483
|
return 'trust_review';
|
|
472
|
-
// "What is" is not, by itself, a definition request. Questions such as
|
|
473
|
-
// "what is revenue by customer and product?" carry an explicit analytical
|
|
474
|
-
// shape and must reach the data cascade. Keep genuine single-concept meaning
|
|
475
|
-
// questions on the fast definition path while treating multi-dimensional or
|
|
476
|
-
// explicitly grouped measure requests as analysis (AGT-001).
|
|
477
|
-
if (/\b(define|definition|meaning of|what is|what are|what does .+ mean)\b/i.test(lower)
|
|
478
|
-
&& !definitionPhraseCarriesAnalyticalShape(input))
|
|
479
|
-
return 'definition';
|
|
480
484
|
if (/\b(anomal|exception|outlier|spike|dip)\b/i.test(lower))
|
|
481
485
|
return 'anomaly';
|
|
482
486
|
if (/\b(compare|versus|vs\.?|cohort)\b/i.test(lower))
|
|
@@ -485,19 +489,33 @@ function inferQuestionMode(input) {
|
|
|
485
489
|
return 'diagnose_change';
|
|
486
490
|
if (/\b(driver|drivers|drove|break\s*down|breakdown|contribute|contribution|top movers?)\b/i.test(lower))
|
|
487
491
|
return 'driver_breakdown';
|
|
492
|
+
if (/\b(top|bottom|best|worst|highest|lowest|least|fewest|minimum|min|maximum|max|rank|ranking|most|leading|leader|leaders)\b/i.test(lower)
|
|
493
|
+
|| hasComparativeMetricRanking(lower))
|
|
494
|
+
return 'ranking';
|
|
488
495
|
if (/\b(profile|overview|360|complete\s+(?:stats|statistics|view)|full\s+(?:stats|statistics|view)|all\s+(?:stats|statistics|metrics)|research|reserach)\b/i.test(lower))
|
|
489
496
|
return 'entity_profile';
|
|
490
|
-
if (input.entities.length > 0 && (input.metricTerms.length > 0 || /\b(show|list|find|give|provide|performance|activity|history|details)\b/i.test(lower)))
|
|
491
|
-
return 'entity_drilldown';
|
|
492
497
|
if (/\b(trend|over time|by\s+(?:day|week|month|quarter|year|season)|daily|weekly|monthly|quarterly|yearly)\b/i.test(lower))
|
|
493
498
|
return 'trend';
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
if (/\b(block|certified|saved|existing|approved|governed)\b/i.test(lower))
|
|
498
|
-
return 'exact_lookup';
|
|
499
|
+
// "Across all customers" states the population for one aggregate; it does
|
|
500
|
+
// not request a customer breakdown. Resolve direct KPI shape before the
|
|
501
|
+
// generic each/every/all list heuristic.
|
|
499
502
|
if (isDirectKpiValueQuestion(lower))
|
|
500
503
|
return 'exact_lookup';
|
|
504
|
+
if (input.metricTerms.length > 0 && input.dimensionTerms.length > 0
|
|
505
|
+
&& /\b(each|every|all|list|show|give|provide)\b/i.test(lower))
|
|
506
|
+
return 'list_by_dimension';
|
|
507
|
+
// "What is" is not, by itself, a definition request. Questions such as
|
|
508
|
+
// "what is revenue by customer and product?" carry an explicit analytical
|
|
509
|
+
// shape and must reach the data cascade. Keep genuine single-concept meaning
|
|
510
|
+
// questions on the fast definition path while treating multi-dimensional or
|
|
511
|
+
// explicitly grouped measure requests as analysis (AGT-001).
|
|
512
|
+
if (/\b(define|definition|meaning of|what is|what are|what does .+ mean)\b/i.test(lower)
|
|
513
|
+
&& !definitionPhraseCarriesAnalyticalShape(input))
|
|
514
|
+
return 'definition';
|
|
515
|
+
if (input.entities.length > 0 && (input.metricTerms.length > 0 || /\b(show|list|find|give|provide|performance|activity|history|details)\b/i.test(lower)))
|
|
516
|
+
return 'entity_drilldown';
|
|
517
|
+
if (/\b(block|certified|saved|existing|approved|governed)\b/i.test(lower))
|
|
518
|
+
return 'exact_lookup';
|
|
501
519
|
if (/\b(show|list|find|which|who|how many|how much|metric|kpi|dashboard|performance|revenue|sales|orders|customers|users)\b/i.test(lower))
|
|
502
520
|
return 'general_analysis';
|
|
503
521
|
// A question that carries real analytical structure — named grouping dimensions
|
|
@@ -515,12 +533,13 @@ function definitionPhraseCarriesAnalyticalShape(input) {
|
|
|
515
533
|
if (input.dimensionTerms.length >= 2)
|
|
516
534
|
return true;
|
|
517
535
|
return input.dimensionTerms.length > 0
|
|
518
|
-
&& /\b(by|per|
|
|
536
|
+
&& /\b(top|bottom|highest|lowest|most|least|by|per|each|every|group(?:ed)? by|split by|break(?:down| down)|info for|details for|got|bought|purchased)\b/i.test(input.lower);
|
|
519
537
|
}
|
|
520
538
|
function routeIntentForMode(mode) {
|
|
521
539
|
switch (mode) {
|
|
522
540
|
case 'exact_lookup': return 'exact_certified_lookup';
|
|
523
541
|
case 'definition': return 'definition_lookup';
|
|
542
|
+
case 'list_by_dimension': return 'ad_hoc_ranking';
|
|
524
543
|
case 'ranking': return 'ad_hoc_ranking';
|
|
525
544
|
case 'entity_profile':
|
|
526
545
|
case 'entity_drilldown': return 'entity_drilldown';
|
|
@@ -535,16 +554,16 @@ function routeIntentForMode(mode) {
|
|
|
535
554
|
}
|
|
536
555
|
}
|
|
537
556
|
function certifiedExactIsPlausible(mode, entities) {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
if (mode === 'ranking' || mode === 'general_analysis')
|
|
541
|
-
return entities.length === 0;
|
|
542
|
-
return false;
|
|
557
|
+
void entities;
|
|
558
|
+
return mode !== 'clarify' && mode !== 'trust_review';
|
|
543
559
|
}
|
|
544
560
|
function generatedSqlIsLikely(mode, certifiedExact) {
|
|
561
|
+
void certifiedExact;
|
|
545
562
|
if (mode === 'clarify' || mode === 'definition' || mode === 'trust_review')
|
|
546
563
|
return false;
|
|
547
|
-
|
|
564
|
+
// This flag controls whether dbt/schema evidence is hydrated as the final
|
|
565
|
+
// fallback. It must not suppress contract-first certified/semantic matching.
|
|
566
|
+
return mode !== 'exact_lookup';
|
|
548
567
|
}
|
|
549
568
|
function researchWorkspaceIsLikely(mode, lower) {
|
|
550
569
|
return ['entity_profile', 'driver_breakdown', 'diagnose_change', 'anomaly', 'trust_review'].includes(mode)
|
|
@@ -555,13 +574,13 @@ function outputShapeForMode(mode, lower, dimensionTerms) {
|
|
|
555
574
|
return 'profile';
|
|
556
575
|
if (mode === 'definition' || mode === 'trust_review')
|
|
557
576
|
return 'narrative';
|
|
558
|
-
if (/\b(chart|graph|visual|plot)\b/i.test(lower) || mode === 'trend')
|
|
577
|
+
if (/\b(chart|graph|visual|plot|sankey|flow\s+diagram|source.?to.?target\s+flow)\b/i.test(lower) || mode === 'trend')
|
|
559
578
|
return 'chart';
|
|
560
|
-
if (dimensionTerms.length >= 2 || /\b(info for|details for|for each|group(?:ed)? by|split by|break(?:down| down))\b/i.test(lower))
|
|
579
|
+
if (mode === 'list_by_dimension' || dimensionTerms.length >= 2 || /\b(info for|details for|for each|group(?:ed)? by|split by|break(?:down| down))\b/i.test(lower))
|
|
561
580
|
return 'table';
|
|
562
581
|
if (/\b(table|list|show all|complete|full)\b/i.test(lower) || mode === 'ranking' || mode === 'entity_drilldown')
|
|
563
582
|
return 'table';
|
|
564
|
-
if (/\bwhat is|how many|how much|total|count|kpi|metric\b/i.test(lower))
|
|
583
|
+
if (/\bwhat (?:is|was|were|are)|how many|how much|total|count|kpi|metric\b/i.test(lower))
|
|
565
584
|
return 'value';
|
|
566
585
|
return 'narrative';
|
|
567
586
|
}
|
|
@@ -579,6 +598,15 @@ function extractEntities(question) {
|
|
|
579
598
|
for (const match of question.matchAll(/\b(?:for|where|only|specific|named|called|profile\s+for|research\s+on|reserach\s+on)\s+([A-Z][A-Za-z0-9&.'-]+(?:\s+[A-Z][A-Za-z0-9&.'-]+){0,5})/g)) {
|
|
580
599
|
entities.push({ text: cleanEntityText(match[1]), source: 'explicit_filter' });
|
|
581
600
|
}
|
|
601
|
+
// A one-token person/account reference after a comparator ("less than
|
|
602
|
+
// Melissa") is still a data value. The generic named-entity regex below only
|
|
603
|
+
// accepts two title-cased words, so without this rule the baseline entity is
|
|
604
|
+
// lost and retrieval repeatedly selects a broad metric or the previous block.
|
|
605
|
+
for (const match of question.matchAll(/\b(?:than|versus|vs\.?)\s+([A-Z][A-Za-z0-9&.'-]+(?:\s+[A-Z][A-Za-z0-9&.'-]+){0,4})/g)) {
|
|
606
|
+
const text = cleanEntityText(match[1]);
|
|
607
|
+
if (text)
|
|
608
|
+
entities.push({ text, source: 'explicit_filter' });
|
|
609
|
+
}
|
|
582
610
|
for (const match of question.matchAll(/\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+){1,4})\b/g)) {
|
|
583
611
|
const text = cleanEntityText(match[1]);
|
|
584
612
|
if (text && !/^(SQL|DQL|KPI|ARR|MRR|NBA|AI|LLM)$/.test(text)) {
|
|
@@ -587,6 +615,19 @@ function extractEntities(question) {
|
|
|
587
615
|
}
|
|
588
616
|
return uniqueEntities(entities).slice(0, 8);
|
|
589
617
|
}
|
|
618
|
+
function extractValueMentions(entities) {
|
|
619
|
+
return entities.map((entity) => ({
|
|
620
|
+
text: entity.text,
|
|
621
|
+
normalizedText: normalizeSearchText(entity.text),
|
|
622
|
+
source: entity.source,
|
|
623
|
+
syntacticRole: entity.source === 'id' || entity.source === 'email'
|
|
624
|
+
? 'entity_identifier'
|
|
625
|
+
: entity.source === 'quoted' || entity.source === 'explicit_filter' || entity.source === 'named_entity'
|
|
626
|
+
? 'filter_value'
|
|
627
|
+
: 'unknown',
|
|
628
|
+
...(entity.typeHint ? { typeHint: entity.typeHint } : {}),
|
|
629
|
+
}));
|
|
630
|
+
}
|
|
590
631
|
function extractMetricTerms(question) {
|
|
591
632
|
const lower = question.toLowerCase();
|
|
592
633
|
const terms = new Set();
|
|
@@ -658,6 +699,26 @@ function removeFilterOnlyDimensionTerms(question, dimensions) {
|
|
|
658
699
|
filterOnly.add(dimension);
|
|
659
700
|
}
|
|
660
701
|
}
|
|
702
|
+
// Natural-language purchase restrictions often put the filtered object after
|
|
703
|
+
// its qualifier instead of before it: "customers who spent on beverage
|
|
704
|
+
// category products". In that sentence the answer grain is customer; products
|
|
705
|
+
// describe what was purchased and must not become an output dimension. Limit
|
|
706
|
+
// this broader rule to transactional grammar so named subjects such as "What
|
|
707
|
+
// changed in Player Stats Data Availability?" retain their analytical grain.
|
|
708
|
+
const hasTransactionalRestriction = /\b(?:spend|spends|spent|spending|buy|buys|bought|buying|purchase|purchases|purchased|purchasing|order|orders|ordered|ordering|sell|sells|sold|selling)\b[^?.!,;]{0,80}\b(?:on|in|within|from|among)\b/i.test(lower);
|
|
709
|
+
if (hasTransactionalRestriction) {
|
|
710
|
+
for (const dimension of dimensions) {
|
|
711
|
+
const word = escapeRegExp(dimension);
|
|
712
|
+
const restriction = new RegExp(`\\b(?:on|in|within|from|among)\\s+(?:the\\s+)?[^?.!,;]{0,60}\\b${word}s?\\b`, 'ig');
|
|
713
|
+
for (const match of lower.matchAll(restriction)) {
|
|
714
|
+
const outsideRestriction = `${lower.slice(0, match.index)} ${lower.slice((match.index ?? 0) + match[0].length)}`;
|
|
715
|
+
const explicitlyGrouped = new RegExp(`\\b(?:by|per|for each|group(?:ed)? by|split by)\\b[^?.!,;]{0,40}\\b${word}s?\\b`, 'i').test(lower);
|
|
716
|
+
const mentionedOutsideRestriction = new RegExp(`\\b${word}s?\\b`, 'i').test(outsideRestriction);
|
|
717
|
+
if (!explicitlyGrouped && !mentionedOutsideRestriction)
|
|
718
|
+
filterOnly.add(dimension);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
661
722
|
return dimensions.filter((dimension) => !filterOnly.has(dimension));
|
|
662
723
|
}
|
|
663
724
|
function pluralizeDimensionWord(word) {
|
|
@@ -684,13 +745,15 @@ function extractFilterTerms(question, entities) {
|
|
|
684
745
|
}
|
|
685
746
|
}
|
|
686
747
|
return uniqueStrings([
|
|
687
|
-
|
|
748
|
+
// Stored member values are phrases, not metadata tokens. Search can still
|
|
749
|
+
// tokenize them separately, but the executable filter contract must not.
|
|
750
|
+
...entities.map((entity) => entity.text),
|
|
688
751
|
...analyticalValues,
|
|
689
752
|
...Array.from(question.matchAll(/\b(?:last|this|next|previous|prior|current)\s+(day|week|month|quarter|year|season)\b/gi)).map((match) => match[0].toLowerCase()),
|
|
690
753
|
]).slice(0, 16);
|
|
691
754
|
}
|
|
692
755
|
function cleanAnalyticalFilterValue(value) {
|
|
693
|
-
|
|
756
|
+
const cleaned = (value ?? '')
|
|
694
757
|
.toLowerCase()
|
|
695
758
|
.replace(/\b(?:highest|lowest|most|least|top|bottom|best|worst|the)\b/g, ' ')
|
|
696
759
|
.replace(/^.*\b(?:on|in|within)\s+/, '')
|
|
@@ -698,6 +761,12 @@ function cleanAnalyticalFilterValue(value) {
|
|
|
698
761
|
.trim()
|
|
699
762
|
.replace(/ies$/, 'y')
|
|
700
763
|
.replace(/s$/, '');
|
|
764
|
+
// Deictic phrases are resolved from the typed prior-result context; they are
|
|
765
|
+
// not warehouse member values. Treating "for this amount" as a literal
|
|
766
|
+
// filter polluted value search and produced invalid SQL predicates.
|
|
767
|
+
if (/^(?:for\s+)?(?:this|that|same|previous|prior)\s+(?:amount|value|result|row)$/.test(cleaned))
|
|
768
|
+
return '';
|
|
769
|
+
return cleaned;
|
|
701
770
|
}
|
|
702
771
|
function expandSearchTerm(raw) {
|
|
703
772
|
const term = normalizeTerm(raw);
|
|
@@ -713,7 +782,10 @@ function expandSearchTerm(raw) {
|
|
|
713
782
|
}
|
|
714
783
|
function extractTimeTerms(question) {
|
|
715
784
|
const terms = [];
|
|
716
|
-
|
|
785
|
+
// Relative determiners are temporal only when followed by a temporal noun.
|
|
786
|
+
// A broad `this \w+` match classified deictic result references such as
|
|
787
|
+
// "this amount" as a time grain and polluted both retrieval and SQL planning.
|
|
788
|
+
for (const match of question.matchAll(/\b(?:today|yesterday|ytd|mtd|qtd|wtd|(?:last|this|next|previous|prior)\s+(?:(?:fiscal|calendar)\s+)?(?:hours?|days?|weeks?|months?|quarters?|years?|seasons?|periods?)|\d{4})\b/gi)) {
|
|
717
789
|
terms.push(match[0].toLowerCase());
|
|
718
790
|
}
|
|
719
791
|
for (const word of ['date', 'day', 'week', 'month', 'quarter', 'year', 'season', 'period']) {
|
|
@@ -738,6 +810,17 @@ function extractFollowUpMetricTerms(followUp, lowerQuestion) {
|
|
|
738
810
|
...cleanStringArray(record.priorResultColumns).filter((column) => METRIC_WORDS.some((word) => new RegExp(`\\b${word}\\b`, 'i').test(column.replace(/_/g, ' ')))),
|
|
739
811
|
]).slice(0, 8);
|
|
740
812
|
}
|
|
813
|
+
function resolveQuestionMetricTerms(lowerQuestion, directTerms, followUpTerms) {
|
|
814
|
+
if (!/\b(?:this|that|same|previous|prior)\s+(?:amount|value)\b/.test(lowerQuestion) || followUpTerms.length === 0) {
|
|
815
|
+
return uniqueStrings([...directTerms, ...followUpTerms]);
|
|
816
|
+
}
|
|
817
|
+
const monetary = followUpTerms.filter((term) => /(?:amount|revenue|sales?|spend|value|price|cost|profit|margin|bookings?|arr|mrr)/i.test(term));
|
|
818
|
+
const resolved = monetary.length > 0 ? monetary : [followUpTerms[0]];
|
|
819
|
+
return uniqueStrings([
|
|
820
|
+
...directTerms.filter((term) => term !== 'amount' && term !== 'value'),
|
|
821
|
+
...resolved,
|
|
822
|
+
]);
|
|
823
|
+
}
|
|
741
824
|
function cleanStringArray(value) {
|
|
742
825
|
if (!Array.isArray(value))
|
|
743
826
|
return [];
|
|
@@ -811,6 +894,10 @@ function hasImplicitRankingRequest(lower) {
|
|
|
811
894
|
function hasComparativeMetricRanking(lower) {
|
|
812
895
|
return /\bmore\s+(?:amount|bookings|cost|count|margin|orders?|points?|profit|quantity|revenue|sales|score|spend|spending|tax|usage|value|volume)\b/.test(lower);
|
|
813
896
|
}
|
|
897
|
+
function isEntityRelativeMeasureComparison(value) {
|
|
898
|
+
return /\b(?:less|lower|fewer|more|higher|greater)\b[^?.!]{0,80}\bthan\b\s+[a-z0-9@._'-]+/i.test(value)
|
|
899
|
+
|| /\b(?:below|under|above|over)\b\s+that\s+of\s+[a-z0-9@._'-]+/i.test(value);
|
|
900
|
+
}
|
|
814
901
|
function wordNumberFromTopN(lower) {
|
|
815
902
|
const words = {
|
|
816
903
|
one: 1,
|
|
@@ -837,6 +924,11 @@ function isPlausibleRequiredColumn(term) {
|
|
|
837
924
|
function extractRequiredOutputs(question, dimensions, measures) {
|
|
838
925
|
const lower = question.toLowerCase();
|
|
839
926
|
const outputs = new Set();
|
|
927
|
+
for (const match of lower.matchAll(/\b([a-z][a-z0-9]*(?:_[a-z0-9]+){1,3})\b/g)) {
|
|
928
|
+
const identifier = canonicalShapeTerm(match[1]);
|
|
929
|
+
if (isPlausibleRequiredColumn(identifier))
|
|
930
|
+
outputs.add(identifier);
|
|
931
|
+
}
|
|
840
932
|
for (const dim of dimensions) {
|
|
841
933
|
if (isPlausibleRequiredColumn(dim))
|
|
842
934
|
outputs.add(dim);
|
|
@@ -847,7 +939,8 @@ function extractRequiredOutputs(question, dimensions, measures) {
|
|
|
847
939
|
// ("average tax info", "tax info by location") is a fuzzy search hint, not a
|
|
848
940
|
// literal column the generated SQL must return — promoting it falsely flags a
|
|
849
941
|
// valid answer as "partial". Single tokens (incl. "count" for KPI blocks) stay.
|
|
850
|
-
|
|
942
|
+
const aggregationOperators = new Set(['total', 'sum', 'average', 'avg', 'minimum', 'min', 'maximum', 'max']);
|
|
943
|
+
if (measure.split(/[_\s]+/).filter(Boolean).length === 1 && !aggregationOperators.has(measure))
|
|
851
944
|
outputs.add(measure);
|
|
852
945
|
}
|
|
853
946
|
if (/\bproduct\s+name\b/i.test(lower))
|
|
@@ -871,13 +964,13 @@ function extractFollowUpReferences(question, followUp) {
|
|
|
871
964
|
const lower = question.toLowerCase();
|
|
872
965
|
const record = followUpRecord(followUp);
|
|
873
966
|
const hasFollowUp = Boolean(record);
|
|
874
|
-
for (const match of lower.matchAll(/\b(these|those|that|same|prior|previous)\s+([a-z][a-z0-9_ -]{1,30})\b/g)) {
|
|
967
|
+
for (const match of lower.matchAll(/\b(this|these|those|that|same|prior|previous)\s+([a-z][a-z0-9_ -]{1,30})\b/g)) {
|
|
875
968
|
const phrase = match[0];
|
|
876
969
|
const noun = canonicalShapeTerm(match[2]);
|
|
877
970
|
let kind = 'ambiguous';
|
|
878
971
|
if (/period|date|day|week|month|quarter|year|time/.test(noun))
|
|
879
972
|
kind = 'prior_timeframe';
|
|
880
|
-
else if (hasFollowUp && /\b(category|product|customer|account|user|region|segment|channel)\b/.test(noun))
|
|
973
|
+
else if (hasFollowUp && /\b(category|product|customer|account|user|region|segment|channel|row|result)\b/.test(noun))
|
|
881
974
|
kind = 'prior_dimension_values';
|
|
882
975
|
else if (hasFollowUp)
|
|
883
976
|
kind = 'prior_entities';
|
|
@@ -887,8 +980,16 @@ function extractFollowUpReferences(question, followUp) {
|
|
|
887
980
|
...resolvedFollowUpValues(record, noun, kind),
|
|
888
981
|
});
|
|
889
982
|
}
|
|
983
|
+
if (record && /\b(they|their|them)\b/.test(lower)) {
|
|
984
|
+
const dimension = pronounFollowUpDimension(lower, record);
|
|
985
|
+
refs.push({
|
|
986
|
+
phrase: lower.match(/\b(they|their|them)\b/)?.[0] ?? 'they',
|
|
987
|
+
kind: dimension ? 'prior_dimension_values' : 'prior_entities',
|
|
988
|
+
...resolvedFollowUpValues(record, dimension, dimension ? 'prior_dimension_values' : 'prior_entities'),
|
|
989
|
+
});
|
|
990
|
+
}
|
|
890
991
|
if (refs.length === 0 && record) {
|
|
891
|
-
const bare = lower.match(/\b(these|those|that|them|same)\b/);
|
|
992
|
+
const bare = lower.match(/\b(this|these|those|that|it|them|same)\b/);
|
|
892
993
|
const dimension = bare ? singleFollowUpDimension(record) : undefined;
|
|
893
994
|
if (bare && dimension) {
|
|
894
995
|
refs.push({
|
|
@@ -900,6 +1001,17 @@ function extractFollowUpReferences(question, followUp) {
|
|
|
900
1001
|
}
|
|
901
1002
|
return refs.slice(0, 8);
|
|
902
1003
|
}
|
|
1004
|
+
function pronounFollowUpDimension(question, record) {
|
|
1005
|
+
const values = cleanStringRecord(record.priorResultValues);
|
|
1006
|
+
const available = new Set(Object.keys(values).map(contextDimensionTerm));
|
|
1007
|
+
if (available.has('customer') && /\b(they|their|them)\b[^.?!]{0,40}\b(buy|bought|purchase|purchased|order|ordered|spend|spent|use|used)\b/.test(question)) {
|
|
1008
|
+
return 'customer';
|
|
1009
|
+
}
|
|
1010
|
+
if (available.has('product') && /\b(they|their|them)\b[^.?!]{0,40}\b(sell|sold|cost|priced)\b/.test(question)) {
|
|
1011
|
+
return 'product';
|
|
1012
|
+
}
|
|
1013
|
+
return singleFollowUpDimension(record);
|
|
1014
|
+
}
|
|
903
1015
|
function followUpDimensionsForRequestedShape(followUp, refs) {
|
|
904
1016
|
const record = followUpRecord(followUp);
|
|
905
1017
|
if (!record || refs.every((ref) => ref.kind !== 'prior_dimension_values'))
|
|
@@ -1019,6 +1131,7 @@ function modeSearchTerms(mode) {
|
|
|
1019
1131
|
switch (mode) {
|
|
1020
1132
|
case 'entity_profile': return ['profile', 'summary', 'details', 'stats', 'statistics', 'history'];
|
|
1021
1133
|
case 'entity_drilldown': return ['detail', 'filter', 'name', 'id'];
|
|
1134
|
+
case 'list_by_dimension': return ['list', 'detail', 'group'];
|
|
1022
1135
|
case 'ranking': return ['rank', 'top', 'bottom', 'total'];
|
|
1023
1136
|
case 'trend': return ['date', 'time', 'month', 'week', 'year', 'trend'];
|
|
1024
1137
|
case 'comparison': return ['compare', 'segment', 'cohort'];
|
|
@@ -1053,7 +1166,7 @@ function relationSearchText(relation) {
|
|
|
1053
1166
|
].join(' ');
|
|
1054
1167
|
}
|
|
1055
1168
|
function isDirectCertifiedQuestion(plan) {
|
|
1056
|
-
return plan.mode === 'exact_lookup' || plan.mode === 'definition' ||
|
|
1169
|
+
return plan.mode === 'exact_lookup' || plan.mode === 'definition' || plan.shouldConsiderCertifiedExact;
|
|
1057
1170
|
}
|
|
1058
1171
|
function hasExactExampleQuestion(object, normalizedQuestion) {
|
|
1059
1172
|
const examples = Array.isArray(object.payload?.examples) ? object.payload.examples : [];
|
|
@@ -1073,9 +1186,13 @@ function rankingDirectionCompatible(question, targetText) {
|
|
|
1073
1186
|
}
|
|
1074
1187
|
function rankingDirection(value) {
|
|
1075
1188
|
const lower = value.toLowerCase();
|
|
1076
|
-
const bottom = /\b(bottom|worst|lowest|least|fewest|minimum|min|smallest)\b/.test(lower)
|
|
1189
|
+
const bottom = /\b(bottom|worst|lowest|least|fewest|minimum|min|smallest)\b/.test(lower)
|
|
1190
|
+
|| /\b(?:less|lower|fewer)\b[^?.!]{0,80}\bthan\b/.test(lower)
|
|
1191
|
+
|| /\b(?:below|under)\b\s+that\s+of\s+[a-z0-9@._'-]+/.test(lower);
|
|
1077
1192
|
const top = /\b(top|best|highest|most|maximum|max|largest|leading|leaders?)\b/.test(lower)
|
|
1078
|
-
|| hasComparativeMetricRanking(lower)
|
|
1193
|
+
|| hasComparativeMetricRanking(lower)
|
|
1194
|
+
|| /\b(?:more|higher|greater)\b[^?.!]{0,80}\bthan\b/.test(lower)
|
|
1195
|
+
|| /\b(?:above|over)\b\s+that\s+of\s+[a-z0-9@._'-]+/.test(lower);
|
|
1079
1196
|
if (bottom && !top)
|
|
1080
1197
|
return 'bottom';
|
|
1081
1198
|
if (top && !bottom)
|
|
@@ -1159,9 +1276,11 @@ function cleanEntityText(value) {
|
|
|
1159
1276
|
}
|
|
1160
1277
|
function isDirectKpiValueQuestion(lower) {
|
|
1161
1278
|
const asksForValue = /\b(what\s+(?:is|was|were|are)|how\s+(?:much|many)|show|report|calculate|give\s+me|tell\s+me)\b/.test(lower);
|
|
1162
|
-
const
|
|
1279
|
+
const aggregateCue = /\b(how\s+(?:much|many)|show|report|calculate|give\s+me|tell\s+me|total|sum|count|number of|average|avg|minimum|min|maximum|max|across\s+all|overall)\b/.test(lower);
|
|
1280
|
+
const temporalScope = /\b(?:last|this|previous|prior|current)\s+(?:day|week|month|quarter|year)\b/.test(lower);
|
|
1281
|
+
const metricLanguage = /\b(revenue|sales|spend|spending|cost|profit|margin|value|amount|usage|arr|mrr|bookings|orders|customers|users|churn|retention|conversion|rate|count|total|points|goals|kpi|metric)\b/.test(lower);
|
|
1163
1282
|
const customGrain = /\b(by|break\s*down|breakdown|drill|compare|versus|vs\.?|segment|cohort|top|bottom|best|worst|highest|lowest|least|fewest|rank|ranking|most|why|changed?|driver|anomal|exception)\b/.test(lower);
|
|
1164
|
-
return asksForValue && metricLanguage && !customGrain;
|
|
1283
|
+
return asksForValue && (aggregateCue || temporalScope) && metricLanguage && !customGrain;
|
|
1165
1284
|
}
|
|
1166
1285
|
function tokenize(value) {
|
|
1167
1286
|
const tokens = new Set();
|