@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.
Files changed (92) hide show
  1. package/dist/agent-run-engine.d.ts +2 -0
  2. package/dist/agent-run-engine.d.ts.map +1 -1
  3. package/dist/agent-run-engine.js +67 -19
  4. package/dist/agent-run-engine.js.map +1 -1
  5. package/dist/agent-run-gates.d.ts.map +1 -1
  6. package/dist/agent-run-gates.js +14 -50
  7. package/dist/agent-run-gates.js.map +1 -1
  8. package/dist/agent-run-planner.d.ts.map +1 -1
  9. package/dist/agent-run-planner.js +14 -4
  10. package/dist/agent-run-planner.js.map +1 -1
  11. package/dist/agentic/toolset.js +32 -2
  12. package/dist/agentic/toolset.js.map +1 -1
  13. package/dist/answer-loop.d.ts +24 -0
  14. package/dist/answer-loop.d.ts.map +1 -1
  15. package/dist/answer-loop.js +387 -55
  16. package/dist/answer-loop.js.map +1 -1
  17. package/dist/answer-shape.js +14 -1
  18. package/dist/answer-shape.js.map +1 -1
  19. package/dist/cascade/budgets.d.ts.map +1 -1
  20. package/dist/cascade/budgets.js +5 -2
  21. package/dist/cascade/budgets.js.map +1 -1
  22. package/dist/conversation/session-store.d.ts +3 -0
  23. package/dist/conversation/session-store.d.ts.map +1 -1
  24. package/dist/conversation/session-store.js +8 -3
  25. package/dist/conversation/session-store.js.map +1 -1
  26. package/dist/conversation/snapshot.d.ts +2 -0
  27. package/dist/conversation/snapshot.d.ts.map +1 -1
  28. package/dist/conversation/snapshot.js +1 -0
  29. package/dist/conversation/snapshot.js.map +1 -1
  30. package/dist/conversation/working-state.d.ts +3 -0
  31. package/dist/conversation/working-state.d.ts.map +1 -1
  32. package/dist/conversation/working-state.js +26 -0
  33. package/dist/conversation/working-state.js.map +1 -1
  34. package/dist/domain-context.d.ts +14 -0
  35. package/dist/domain-context.d.ts.map +1 -1
  36. package/dist/domain-context.js +10 -0
  37. package/dist/domain-context.js.map +1 -1
  38. package/dist/embeddings/provider.d.ts +3 -1
  39. package/dist/embeddings/provider.d.ts.map +1 -1
  40. package/dist/embeddings/provider.js +19 -8
  41. package/dist/embeddings/provider.js.map +1 -1
  42. package/dist/index.d.ts +6 -3
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +33 -4
  45. package/dist/index.js.map +1 -1
  46. package/dist/intent-controller.d.ts +15 -0
  47. package/dist/intent-controller.d.ts.map +1 -1
  48. package/dist/intent-controller.js +4 -1
  49. package/dist/intent-controller.js.map +1 -1
  50. package/dist/meaning-resolution.d.ts +110 -0
  51. package/dist/meaning-resolution.d.ts.map +1 -0
  52. package/dist/meaning-resolution.js +155 -0
  53. package/dist/meaning-resolution.js.map +1 -0
  54. package/dist/metadata/analysis-planner.d.ts +15 -1
  55. package/dist/metadata/analysis-planner.d.ts.map +1 -1
  56. package/dist/metadata/analysis-planner.js +169 -50
  57. package/dist/metadata/analysis-planner.js.map +1 -1
  58. package/dist/metadata/block-fit.d.ts.map +1 -1
  59. package/dist/metadata/block-fit.js +51 -5
  60. package/dist/metadata/block-fit.js.map +1 -1
  61. package/dist/metadata/catalog.d.ts +62 -3
  62. package/dist/metadata/catalog.d.ts.map +1 -1
  63. package/dist/metadata/catalog.js +911 -98
  64. package/dist/metadata/catalog.js.map +1 -1
  65. package/dist/metadata/grain-gate.js +27 -9
  66. package/dist/metadata/grain-gate.js.map +1 -1
  67. package/dist/metadata/meaning-evidence.d.ts +70 -0
  68. package/dist/metadata/meaning-evidence.d.ts.map +1 -0
  69. package/dist/metadata/meaning-evidence.js +333 -0
  70. package/dist/metadata/meaning-evidence.js.map +1 -0
  71. package/dist/metadata/metric-match.d.ts.map +1 -1
  72. package/dist/metadata/metric-match.js +5 -2
  73. package/dist/metadata/metric-match.js.map +1 -1
  74. package/dist/router.d.ts +16 -4
  75. package/dist/router.d.ts.map +1 -1
  76. package/dist/router.js +409 -21
  77. package/dist/router.js.map +1 -1
  78. package/dist/semantic-bridge/compose.d.ts +12 -0
  79. package/dist/semantic-bridge/compose.d.ts.map +1 -1
  80. package/dist/semantic-bridge/compose.js +177 -14
  81. package/dist/semantic-bridge/compose.js.map +1 -1
  82. package/dist/semantic-bridge/member-select.d.ts.map +1 -1
  83. package/dist/semantic-bridge/member-select.js +59 -10
  84. package/dist/semantic-bridge/member-select.js.map +1 -1
  85. package/dist/skills/loader.d.ts.map +1 -1
  86. package/dist/skills/loader.js +35 -33
  87. package/dist/skills/loader.js.map +1 -1
  88. package/dist/synthesize.d.ts +2 -0
  89. package/dist/synthesize.d.ts.map +1 -1
  90. package/dist/synthesize.js +84 -13
  91. package/dist/synthesize.js.map +1 -1
  92. 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
- const lower = cleanQuestion.toLowerCase();
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 metricTerms = uniqueStrings([
50
- ...extractMetricTerms(cleanQuestion),
51
- ...extractFollowUpMetricTerms(followUp, lower),
52
- ]);
53
- const extractedDimensionTerms = extractDimensionTerms(cleanQuestion);
54
- const filterTerms = extractFilterTerms(cleanQuestion, entities);
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
- const shouldConsiderCertifiedExact = certifiedExactIsPlausible(mode, entities)
61
- && !(mode === 'general_analysis' && definitionPhraseCarriesAnalyticalShape({ lower, metricTerms, dimensionTerms }));
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.needsGeneratedSql || plan.entities.length > 0 || plan.mode === 'entity_profile';
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
- if (/\b(top|bottom|best|worst|highest|lowest|least|fewest|minimum|min|maximum|max|rank|ranking|most|leading|leader|leaders)\b/i.test(lower)
495
- || hasComparativeMetricRanking(lower))
496
- return 'ranking';
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|for each|group(?:ed)? by|split by|break(?:down| down)|info for|details for)\b/i.test(input.lower);
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
- if (mode === 'exact_lookup' || mode === 'definition')
539
- return true;
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
- return !certifiedExact || ['entity_profile', 'entity_drilldown', 'comparison', 'driver_breakdown', 'diagnose_change', 'anomaly', 'trend'].includes(mode);
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
- ...entities.flatMap((entity) => tokenize(entity.text)),
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
- return (value ?? '')
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
- for (const match of question.matchAll(/\b(?:today|yesterday|ytd|mtd|qtd|wtd|last\s+\w+|this\s+\w+|next\s+\w+|previous\s+\w+|prior\s+\w+|\d{4})\b/gi)) {
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
- if (measure.split(/[_\s]+/).filter(Boolean).length === 1)
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' || (plan.shouldConsiderCertifiedExact && !plan.needsGeneratedSql);
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 metricLanguage = /\b(revenue|sales|arr|mrr|bookings|orders|customers|users|churn|retention|conversion|rate|count|total|points|goals|kpi|metric)\b/.test(lower);
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();