@duckcodeailabs/dql-agent 1.13.5 → 1.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-run-engine.d.ts +79 -4
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +457 -39
- 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 +13 -0
- package/dist/agent-run-gates.js.map +1 -1
- package/dist/agent-run-store.d.ts.map +1 -1
- package/dist/agent-run-store.js +42 -25
- package/dist/agent-run-store.js.map +1 -1
- package/dist/agentic/analyst-loop.d.ts +91 -0
- package/dist/agentic/analyst-loop.d.ts.map +1 -0
- package/dist/agentic/analyst-loop.js +311 -0
- package/dist/agentic/analyst-loop.js.map +1 -0
- package/dist/agentic/assumptions.d.ts +62 -0
- package/dist/agentic/assumptions.d.ts.map +1 -0
- package/dist/agentic/assumptions.js +73 -0
- package/dist/agentic/assumptions.js.map +1 -0
- package/dist/agentic/business-explanation.d.ts +56 -0
- package/dist/agentic/business-explanation.d.ts.map +1 -0
- package/dist/agentic/business-explanation.js +130 -0
- package/dist/agentic/business-explanation.js.map +1 -0
- package/dist/agentic/identifier-ledger.d.ts +74 -0
- package/dist/agentic/identifier-ledger.d.ts.map +1 -0
- package/dist/agentic/identifier-ledger.js +151 -0
- package/dist/agentic/identifier-ledger.js.map +1 -0
- package/dist/agentic/ledger-tools.d.ts +68 -0
- package/dist/agentic/ledger-tools.d.ts.map +1 -0
- package/dist/agentic/ledger-tools.js +198 -0
- package/dist/agentic/ledger-tools.js.map +1 -0
- package/dist/agentic/orchestrator-policy.d.ts +60 -0
- package/dist/agentic/orchestrator-policy.d.ts.map +1 -0
- package/dist/agentic/orchestrator-policy.js +73 -0
- package/dist/agentic/orchestrator-policy.js.map +1 -0
- package/dist/agentic/orchestrator.d.ts +54 -0
- package/dist/agentic/orchestrator.d.ts.map +1 -0
- package/dist/agentic/orchestrator.js +67 -0
- package/dist/agentic/orchestrator.js.map +1 -0
- package/dist/agentic/preview-tool.d.ts +36 -0
- package/dist/agentic/preview-tool.d.ts.map +1 -0
- package/dist/agentic/preview-tool.js +71 -0
- package/dist/agentic/preview-tool.js.map +1 -0
- package/dist/agentic/rerank.d.ts +50 -0
- package/dist/agentic/rerank.d.ts.map +1 -0
- package/dist/agentic/rerank.js +140 -0
- package/dist/agentic/rerank.js.map +1 -0
- package/dist/agentic/research/hypothesis.d.ts +110 -0
- package/dist/agentic/research/hypothesis.d.ts.map +1 -0
- package/dist/agentic/research/hypothesis.js +125 -0
- package/dist/agentic/research/hypothesis.js.map +1 -0
- package/dist/agentic/research/synthesis.d.ts +32 -0
- package/dist/agentic/research/synthesis.d.ts.map +1 -0
- package/dist/agentic/research/synthesis.js +94 -0
- package/dist/agentic/research/synthesis.js.map +1 -0
- package/dist/agentic/research-agent.d.ts +54 -0
- package/dist/agentic/research-agent.d.ts.map +1 -0
- package/dist/agentic/research-agent.js +176 -0
- package/dist/agentic/research-agent.js.map +1 -0
- package/dist/agentic/sql-authorization.d.ts +131 -0
- package/dist/agentic/sql-authorization.d.ts.map +1 -0
- package/dist/agentic/sql-authorization.js +232 -0
- package/dist/agentic/sql-authorization.js.map +1 -0
- package/dist/agentic/tool-loop.d.ts +29 -0
- package/dist/agentic/tool-loop.d.ts.map +1 -1
- package/dist/agentic/tool-loop.js +129 -52
- package/dist/agentic/tool-loop.js.map +1 -1
- package/dist/agentic/toolset.d.ts.map +1 -1
- package/dist/agentic/toolset.js +155 -0
- package/dist/agentic/toolset.js.map +1 -1
- package/dist/agentic/turn-plan.d.ts +35 -0
- package/dist/agentic/turn-plan.d.ts.map +1 -0
- package/dist/agentic/turn-plan.js +108 -0
- package/dist/agentic/turn-plan.js.map +1 -0
- package/dist/agentic/value-probe.d.ts +89 -0
- package/dist/agentic/value-probe.d.ts.map +1 -0
- package/dist/agentic/value-probe.js +304 -0
- package/dist/agentic/value-probe.js.map +1 -0
- package/dist/analytical-orchestration.d.ts +336 -0
- package/dist/analytical-orchestration.d.ts.map +1 -0
- package/dist/analytical-orchestration.js +631 -0
- package/dist/analytical-orchestration.js.map +1 -0
- package/dist/analytical-request-policy.d.ts +26 -0
- package/dist/analytical-request-policy.d.ts.map +1 -0
- package/dist/analytical-request-policy.js +82 -0
- package/dist/analytical-request-policy.js.map +1 -0
- package/dist/analytical-result-facts.d.ts +14 -1
- package/dist/analytical-result-facts.d.ts.map +1 -1
- package/dist/analytical-result-facts.js +76 -1
- package/dist/analytical-result-facts.js.map +1 -1
- package/dist/answer-loop.d.ts +61 -16
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +256 -36
- package/dist/answer-loop.js.map +1 -1
- package/dist/cascade/budgets.d.ts.map +1 -1
- package/dist/cascade/budgets.js +8 -1
- package/dist/cascade/budgets.js.map +1 -1
- package/dist/cascade/packer.d.ts +88 -0
- package/dist/cascade/packer.d.ts.map +1 -0
- package/dist/cascade/packer.js +106 -0
- package/dist/cascade/packer.js.map +1 -0
- package/dist/conversation/result-ops.d.ts +18 -3
- package/dist/conversation/result-ops.d.ts.map +1 -1
- package/dist/conversation/result-ops.js +77 -5
- package/dist/conversation/result-ops.js.map +1 -1
- package/dist/conversation/rolling-summary.d.ts.map +1 -1
- package/dist/conversation/rolling-summary.js +7 -2
- package/dist/conversation/rolling-summary.js.map +1 -1
- package/dist/conversation/session-store.d.ts +3 -0
- package/dist/conversation/session-store.d.ts.map +1 -1
- package/dist/conversation/session-store.js +8 -4
- package/dist/conversation/session-store.js.map +1 -1
- package/dist/conversation/snapshot.d.ts.map +1 -1
- package/dist/conversation/snapshot.js +13 -1
- package/dist/conversation/snapshot.js.map +1 -1
- package/dist/conversation/turn-trust.d.ts.map +1 -1
- package/dist/conversation/turn-trust.js +6 -0
- package/dist/conversation/turn-trust.js.map +1 -1
- package/dist/hints/store.d.ts.map +1 -1
- package/dist/hints/store.js +15 -5
- package/dist/hints/store.js.map +1 -1
- package/dist/index.d.ts +27 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -4
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +8 -0
- package/dist/intent-controller.d.ts.map +1 -1
- package/dist/intent-controller.js +65 -0
- package/dist/intent-controller.js.map +1 -1
- package/dist/meaning-resolution.d.ts +7 -0
- package/dist/meaning-resolution.d.ts.map +1 -1
- package/dist/meaning-resolution.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 +41 -1
- package/dist/metadata/block-fit.js.map +1 -1
- package/dist/metadata/catalog.d.ts +44 -23
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +363 -53
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/meaning-evidence.d.ts.map +1 -1
- package/dist/metadata/meaning-evidence.js +101 -8
- package/dist/metadata/meaning-evidence.js.map +1 -1
- package/dist/metadata/metric-match.d.ts +32 -0
- package/dist/metadata/metric-match.d.ts.map +1 -1
- package/dist/metadata/metric-match.js +120 -9
- package/dist/metadata/metric-match.js.map +1 -1
- package/dist/research-loop.d.ts +33 -2
- package/dist/research-loop.d.ts.map +1 -1
- package/dist/research-loop.js +44 -4
- package/dist/research-loop.js.map +1 -1
- package/dist/resolved-analytical-plan.d.ts.map +1 -1
- package/dist/resolved-analytical-plan.js +92 -5
- package/dist/resolved-analytical-plan.js.map +1 -1
- package/dist/router.d.ts +36 -16
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +604 -25
- package/dist/router.js.map +1 -1
- package/package.json +4 -4
package/dist/router.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assumeDominantCandidate } from './agentic/assumptions.js';
|
|
1
2
|
/**
|
|
2
3
|
* Hybrid router — deterministic-first, LLM-assisted for the ambiguous middle.
|
|
3
4
|
*
|
|
@@ -578,16 +579,26 @@ function routedClarificationQuestion(resolution, bindings, options) {
|
|
|
578
579
|
?? `Which governed binding should I use before running this query?`;
|
|
579
580
|
}
|
|
580
581
|
function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected) {
|
|
582
|
+
const selectedId = selected.qualifiedId ?? selected.id;
|
|
583
|
+
const message = `The selected governed meaning ${selectedId} does not prove the complete requested metric, dimension, filter, and grain tuple. DQL did not substitute a correlated metric or execute a different artifact.`;
|
|
581
584
|
return {
|
|
582
585
|
...base,
|
|
583
|
-
action: '
|
|
584
|
-
confidence:
|
|
585
|
-
reason:
|
|
586
|
+
action: 'block',
|
|
587
|
+
confidence: 1,
|
|
588
|
+
reason: message,
|
|
586
589
|
source: 'heuristic',
|
|
587
590
|
category: 'data_lookup',
|
|
588
591
|
depth: 'quick',
|
|
589
592
|
retrievalEvidence: retrievalTrace(evidence, candidates),
|
|
590
593
|
requiresClarification: false,
|
|
594
|
+
terminalOutcome: {
|
|
595
|
+
kind: 'modeling_gap',
|
|
596
|
+
code: 'ANALYTICAL_MODELING_GAP',
|
|
597
|
+
message,
|
|
598
|
+
candidateIds: [selectedId],
|
|
599
|
+
},
|
|
600
|
+
resolvedAnalyticalPlan: undefined,
|
|
601
|
+
meaningResolution: undefined,
|
|
591
602
|
};
|
|
592
603
|
}
|
|
593
604
|
function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
|
|
@@ -793,6 +804,114 @@ function buildEvidenceClarification(candidates, missing = []) {
|
|
|
793
804
|
return `I found relevant governed context, but need ${missing.join(" and ")}. What should I use?`;
|
|
794
805
|
return "Which governed business meaning should I use for this question?";
|
|
795
806
|
}
|
|
807
|
+
/**
|
|
808
|
+
* A distinct-entity count is not a useful ranking measure at the same entity
|
|
809
|
+
* grain: every customer normally has a count of one. Keep the candidate in the
|
|
810
|
+
* evidence trace, but do not let lexical relevance freeze it as the answer to
|
|
811
|
+
* "top customers". This is deliberately a semantic suitability check, not a
|
|
812
|
+
* name-based ban; an explicit "top customers by customer count" request remains
|
|
813
|
+
* the user's choice and can proceed through normal compatibility checks.
|
|
814
|
+
*/
|
|
815
|
+
function isDegenerateRankingMetric(question, evidence, candidate) {
|
|
816
|
+
if (questionTypeFromText(question) !== 'ranking')
|
|
817
|
+
return false;
|
|
818
|
+
if (candidate.kind !== 'semantic_metric' && candidate.kind !== 'semantic_member')
|
|
819
|
+
return false;
|
|
820
|
+
const capability = normalizeEvidenceAnalyticalCapability(candidate).capability;
|
|
821
|
+
const aggregation = normalizeMetricPhrase(candidate.aggregation ?? capability?.aggregation ?? '');
|
|
822
|
+
if (!aggregation || !/^(count|count distinct|count unique|count distinct values)$/.test(aggregation))
|
|
823
|
+
return false;
|
|
824
|
+
const questionTerms = new Set(substantiveLexicalTokens(question));
|
|
825
|
+
const entityTerms = [
|
|
826
|
+
candidate.primaryEntity ?? '',
|
|
827
|
+
...(candidate.analyticalCapability?.resultGrainIds ?? []),
|
|
828
|
+
...(candidate.dimensions ?? []),
|
|
829
|
+
...(evidence.parsedIntent?.dimensions ?? []),
|
|
830
|
+
].flatMap((value) => substantiveLexicalTokens(value));
|
|
831
|
+
const metricTerms = [candidate.name, candidate.qualifiedId ?? '', ...(candidate.aliases ?? [])]
|
|
832
|
+
.flatMap((value) => substantiveLexicalTokens(value));
|
|
833
|
+
return entityTerms.some((term) => questionTerms.has(term))
|
|
834
|
+
&& metricTerms.some((term) => entityTerms.includes(term));
|
|
835
|
+
}
|
|
836
|
+
function hasExplicitRankingMeasure(question, evidence) {
|
|
837
|
+
const parsed = [
|
|
838
|
+
...(evidence.parsedIntent?.measures ?? []),
|
|
839
|
+
...extractRankingMeasurePhrases(question),
|
|
840
|
+
].map(normalizeMetricPhrase).filter(Boolean);
|
|
841
|
+
return parsed.length > 0;
|
|
842
|
+
}
|
|
843
|
+
function extractRankingMeasurePhrases(question) {
|
|
844
|
+
const matches = [];
|
|
845
|
+
for (const pattern of [
|
|
846
|
+
/\b(?:by|based on|using|with|for)\s+(?:the\s+)?([a-z][a-z0-9_. -]{1,80}?)(?=\s+(?:among|for each|per|in|where|during|over)|[?.!,]|$)/gi,
|
|
847
|
+
/\b(?:highest|lowest|most|least)\s+([a-z][a-z0-9_. -]{1,80}?)(?=\s+(?:among|for each|per|in|where|during|over)|[?.!,]|$)/gi,
|
|
848
|
+
]) {
|
|
849
|
+
for (const match of question.matchAll(pattern))
|
|
850
|
+
if (match[1])
|
|
851
|
+
matches.push(match[1]);
|
|
852
|
+
}
|
|
853
|
+
return matches;
|
|
854
|
+
}
|
|
855
|
+
function rankingMetricChoiceDecision(base, evidence, candidates, selected, question) {
|
|
856
|
+
const options = candidates
|
|
857
|
+
.filter((candidate) => candidate.id !== selected.id
|
|
858
|
+
&& candidate.compatibility !== 'incompatible'
|
|
859
|
+
&& candidate.kind === 'semantic_metric'
|
|
860
|
+
&& !isDegenerateRankingMetric(question, evidence, candidate))
|
|
861
|
+
.slice(0, 3);
|
|
862
|
+
if (options.length === 0) {
|
|
863
|
+
return unanswerableClarificationFallback(base, retrievalTrace(evidence, candidates), `${selected.name} counts customers and cannot rank them, and no alternative governed measure was retrieved, so DQL continued into the review-required generated lane instead of asking a question with no selectable answer.`);
|
|
864
|
+
}
|
|
865
|
+
const labels = options.map((candidate) => renderCandidateChoice(candidate)).join(' or ');
|
|
866
|
+
return {
|
|
867
|
+
...base,
|
|
868
|
+
action: 'clarify',
|
|
869
|
+
confidence: 1,
|
|
870
|
+
source: 'heuristic',
|
|
871
|
+
category: 'unclear',
|
|
872
|
+
depth: 'quick',
|
|
873
|
+
followsUp: true,
|
|
874
|
+
requiresClarification: true,
|
|
875
|
+
reason: `${selected.name} counts customers; it cannot distinguish individual customers for a top-customer ranking.`,
|
|
876
|
+
clarifyingQuestion: `That metric counts unique customers and cannot rank individual customers. Which measure should rank them: ${labels}?`,
|
|
877
|
+
clarificationOptions: buildClarificationOptions(options),
|
|
878
|
+
retrievalEvidence: retrievalTrace(evidence, candidates),
|
|
879
|
+
resolvedAnalyticalPlan: undefined,
|
|
880
|
+
meaningResolution: undefined,
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
function preventDegenerateRankingResolution(resolution, evidence, candidates, question) {
|
|
884
|
+
if (hasExplicitRankingMeasure(question, evidence))
|
|
885
|
+
return resolution;
|
|
886
|
+
const selected = candidates.find((candidate) => candidate.id === resolution.recommendedExecutionId
|
|
887
|
+
|| resolution.selectedConceptIds.includes(candidate.id));
|
|
888
|
+
if (!selected || !isDegenerateRankingMetric(question, evidence, selected))
|
|
889
|
+
return resolution;
|
|
890
|
+
const alternatives = candidates
|
|
891
|
+
.filter((candidate) => candidate.id !== selected.id
|
|
892
|
+
&& candidate.kind === 'semantic_metric'
|
|
893
|
+
&& candidate.compatibility !== 'incompatible'
|
|
894
|
+
&& !isDegenerateRankingMetric(question, evidence, candidate))
|
|
895
|
+
.slice(0, 3);
|
|
896
|
+
const alternativeLabels = alternatives.map(renderCandidateChoice).join(' or ');
|
|
897
|
+
return {
|
|
898
|
+
...resolution,
|
|
899
|
+
confidence: 'low',
|
|
900
|
+
recommendedRoute: 'clarify',
|
|
901
|
+
recommendedExecutionId: undefined,
|
|
902
|
+
selectedConceptIds: [],
|
|
903
|
+
analyticalFrame: undefined,
|
|
904
|
+
missingInformation: [
|
|
905
|
+
...new Set([
|
|
906
|
+
...resolution.missingInformation,
|
|
907
|
+
`${selected.name} counts the ranked entity and is not a suitable ranking measure`,
|
|
908
|
+
]),
|
|
909
|
+
],
|
|
910
|
+
clarifyingQuestion: alternativeLabels
|
|
911
|
+
? `I found ${selected.name}, but it counts the ranked entity and cannot identify the top individual customers. Which measure should I use: ${alternativeLabels}?`
|
|
912
|
+
: `I found ${selected.name}, but it counts the ranked entity and cannot identify the top individual customers. Which measure should I use for the ranking?`,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
796
915
|
function directResolution(request, evidence, candidate, candidates) {
|
|
797
916
|
const inferredQuestionType = questionTypeFromText(request.question);
|
|
798
917
|
const questionType = inferredQuestionType === 'definition'
|
|
@@ -930,12 +1049,34 @@ mayAssumeInterpretation = true) {
|
|
|
930
1049
|
if (multiMetricPrimary) {
|
|
931
1050
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, planMode);
|
|
932
1051
|
}
|
|
933
|
-
const
|
|
1052
|
+
const rankingCandidates = hasExplicitRankingMeasure(request.question, evidence)
|
|
1053
|
+
? candidates
|
|
1054
|
+
: candidates.filter((candidate) => !isDegenerateRankingMetric(request.question, evidence, candidate));
|
|
1055
|
+
if (questionTypeFromText(request.question) === 'ranking'
|
|
1056
|
+
&& !hasExplicitRankingMeasure(request.question, evidence)) {
|
|
1057
|
+
// Assume the measure where one is clearly indicated, and BIND it through the
|
|
1058
|
+
// same resolution path an explicit selection takes — an assumption that
|
|
1059
|
+
// cannot freeze a plan is refused downstream and surfaces as `blocked` with
|
|
1060
|
+
// no options, which is worse than the question it replaced.
|
|
1061
|
+
const assumed = mayAssumeInterpretation
|
|
1062
|
+
? assumableRankingMeasure(request.question, rankingCandidates)
|
|
1063
|
+
: undefined;
|
|
1064
|
+
if (assumed) {
|
|
1065
|
+
return {
|
|
1066
|
+
...routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, assumed.candidate, candidates), 'heuristic', request.question, planMode),
|
|
1067
|
+
assumptions: [assumed.assumption],
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence, rankingCandidates);
|
|
1071
|
+
}
|
|
1072
|
+
const exactCompatible = candidates.filter((candidate) => candidate.exactMatch
|
|
1073
|
+
&& candidate.compatibility !== "incompatible"
|
|
1074
|
+
&& rankingCandidates.includes(candidate));
|
|
934
1075
|
if (exactCompatible.length === 1 &&
|
|
935
1076
|
!hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
|
|
936
1077
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, planMode);
|
|
937
1078
|
}
|
|
938
|
-
const semanticMetric = uniqueExecutableSemanticMetric(evidence,
|
|
1079
|
+
const semanticMetric = uniqueExecutableSemanticMetric(evidence, rankingCandidates);
|
|
939
1080
|
if (semanticMetric) {
|
|
940
1081
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, semanticMetric, candidates), "heuristic", request.question, planMode);
|
|
941
1082
|
}
|
|
@@ -944,7 +1085,7 @@ mayAssumeInterpretation = true) {
|
|
|
944
1085
|
// `type: simple` metric, the measure it wraps, and the model that holds them
|
|
945
1086
|
// were offered as three competing "meanings" of the same number.
|
|
946
1087
|
const best = mayAssumeInterpretation
|
|
947
|
-
? bestGovernedInterpretation(request.question,
|
|
1088
|
+
? bestGovernedInterpretation(request.question, rankingCandidates)
|
|
948
1089
|
: undefined;
|
|
949
1090
|
if (best) {
|
|
950
1091
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, best, candidates), "heuristic", request.question, planMode);
|
|
@@ -981,11 +1122,67 @@ export function collapseRedundantGovernedCandidates(question, candidates) {
|
|
|
981
1122
|
const byScore = [...new Map(candidates
|
|
982
1123
|
.filter((candidate) => candidate.eligible !== false && candidate.compatibility !== 'incompatible')
|
|
983
1124
|
.map((candidate) => [candidate.id, candidate])).values()].sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id));
|
|
984
|
-
|
|
1125
|
+
// An entity is a JOIN KEY. It is a reading of "how many customers", but never
|
|
1126
|
+
// of "what customer type is <member>" — there the reader named a field and a
|
|
1127
|
+
// member, and the entity cannot answer either. The trigger used to be a
|
|
1128
|
+
// four-word list (name/label/title/description), so "customer type", "region",
|
|
1129
|
+
// and "when did X first order" all left the entity competing and turned an
|
|
1130
|
+
// ordinary attribute lookup into a bind-interrogation. Recognise the
|
|
1131
|
+
// interrogative FORM as well as the vocabulary; `hasDimension` below still
|
|
1132
|
+
// requires that a real attribute was actually retrieved.
|
|
1133
|
+
const wantsAttribute = /\b(names?|labels?|titles?|descriptions?|types?|status(?:es)?|categor(?:y|ies)|segments?|tiers?|regions?|emails?|addresses?)\b/i.test(question)
|
|
1134
|
+
|| /\b(what|which|when|where)\b[^?]*\b(is|are|was|were|does|do|did|belongs?)\b/i.test(question);
|
|
985
1135
|
const hasDimension = byScore.some((candidate) => candidate.semanticObjectType === 'dimension');
|
|
986
|
-
|
|
987
|
-
|
|
1136
|
+
// An entity arrives as a semantic-layer entity OR as a DQL modeling entity
|
|
1137
|
+
// (`dql:entity:…`, kind `dql_modeling`). Testing only `semanticObjectType`
|
|
1138
|
+
// left the DQL one competing, so the interrogation survived the fix above.
|
|
1139
|
+
// Check BOTH identities: a DQL entity's `qualifiedId` is the bare
|
|
1140
|
+
// `commerce::entity::customer`, so testing the qualified id alone still let
|
|
1141
|
+
// it through.
|
|
1142
|
+
const isEntityCandidate = (candidate) => candidate.semanticObjectType === 'entity'
|
|
1143
|
+
|| [candidate.id, candidate.qualifiedId ?? ''].some((identity) => /(^|:)entity(:|::)/i.test(identity));
|
|
1144
|
+
const entityFiltered = wantsAttribute && hasDimension
|
|
1145
|
+
? byScore.filter((candidate) => !isEntityCandidate(candidate))
|
|
988
1146
|
: byScore;
|
|
1147
|
+
// Within an attribute question, a candidate matching only a SUB-TOKEN of the
|
|
1148
|
+
// requested field is a lexical decoy, not a competing reading. "What customer
|
|
1149
|
+
// type is <member>?" dragged in `raw_products.type` and
|
|
1150
|
+
// `orders.new_customer_orders` purely because they contain "type" and
|
|
1151
|
+
// "customer", and two decoys are enough to trip the ambiguity gate and turn
|
|
1152
|
+
// the lookup into an interrogation. Score how much of the question each
|
|
1153
|
+
// candidate actually accounts for — including the fields a block declares,
|
|
1154
|
+
// which is how the block that OUTPUTS `customer_type` outranks a column
|
|
1155
|
+
// merely named `type` — and keep only the most specific matches.
|
|
1156
|
+
const normalizedQuestion = normalizeMetricPhrase(question);
|
|
1157
|
+
const phraseSpecificity = (candidate) => {
|
|
1158
|
+
const terms = [
|
|
1159
|
+
candidate.name,
|
|
1160
|
+
...(candidate.aliases ?? []),
|
|
1161
|
+
...(candidate.dimensions ?? []),
|
|
1162
|
+
...(candidate.compatibilityFacts ?? [])
|
|
1163
|
+
.filter((fact) => fact.startsWith('output: '))
|
|
1164
|
+
.map((fact) => fact.slice('output: '.length)),
|
|
1165
|
+
].map((term) => normalizeMetricPhrase(String(term ?? '').split(/[.:/]/).at(-1) ?? ''));
|
|
1166
|
+
let best = 0;
|
|
1167
|
+
for (const term of terms) {
|
|
1168
|
+
if (!term)
|
|
1169
|
+
continue;
|
|
1170
|
+
const matches = normalizedQuestion === term
|
|
1171
|
+
|| normalizedQuestion.startsWith(`${term} `)
|
|
1172
|
+
|| normalizedQuestion.endsWith(` ${term}`)
|
|
1173
|
+
|| normalizedQuestion.includes(` ${term} `);
|
|
1174
|
+
if (matches)
|
|
1175
|
+
best = Math.max(best, term.split(' ').length);
|
|
1176
|
+
}
|
|
1177
|
+
return best;
|
|
1178
|
+
};
|
|
1179
|
+
const specificity = new Map(entityFiltered.map((candidate) => [candidate.id, phraseSpecificity(candidate)]));
|
|
1180
|
+
const bestSpecificity = Math.max(0, ...specificity.values());
|
|
1181
|
+
// Only prune when something matched a MULTI-word field name. A single shared
|
|
1182
|
+
// token is not enough evidence to call the others decoys.
|
|
1183
|
+
const kindFiltered = wantsAttribute && hasDimension && bestSpecificity >= 2
|
|
1184
|
+
? entityFiltered.filter((candidate) => (specificity.get(candidate.id) ?? 0) === bestSpecificity)
|
|
1185
|
+
: entityFiltered;
|
|
989
1186
|
const representatives = new Map();
|
|
990
1187
|
const passthrough = [];
|
|
991
1188
|
for (const candidate of kindFiltered) {
|
|
@@ -1000,7 +1197,44 @@ export function collapseRedundantGovernedCandidates(question, candidates) {
|
|
|
1000
1197
|
representatives.set(key, candidate);
|
|
1001
1198
|
}
|
|
1002
1199
|
}
|
|
1003
|
-
|
|
1200
|
+
// A certified block that already OUTPUTS an attribute is not a competing
|
|
1201
|
+
// MEANING of that attribute — it is the same reading at higher authority.
|
|
1202
|
+
// Keeping both turned an ordinary attribute lookup ("what customer type is
|
|
1203
|
+
// <member>?") into a "Which governed meaning should DQL bind: customer_profile
|
|
1204
|
+
// or customers.customer_type?" interrogation, even though the block declares
|
|
1205
|
+
// that exact output, sits at the requested grain, and permits the member
|
|
1206
|
+
// filter. The cascade already says certified outranks semantic for one
|
|
1207
|
+
// reading; this stops the tie from being mistaken for ambiguity.
|
|
1208
|
+
const certifiedCoverage = passthrough
|
|
1209
|
+
.filter((candidate) => candidate.kind === 'certified_block')
|
|
1210
|
+
.map((candidate) => new Set([
|
|
1211
|
+
...(candidate.dimensions ?? []),
|
|
1212
|
+
...(candidate.compatibilityFacts ?? [])
|
|
1213
|
+
.filter((fact) => fact.startsWith('output: '))
|
|
1214
|
+
.map((fact) => fact.slice('output: '.length)),
|
|
1215
|
+
].map((value) => normalizeMetricPhrase(String(value).split(/[.:/]/).at(-1) ?? ''))
|
|
1216
|
+
.filter(Boolean)));
|
|
1217
|
+
const survivingPassthrough = certifiedCoverage.length === 0
|
|
1218
|
+
? passthrough
|
|
1219
|
+
: passthrough.filter((candidate) => {
|
|
1220
|
+
// The same field can arrive three ways — the block's declared output, the
|
|
1221
|
+
// semantic dimension, and the raw warehouse/dbt column. Only the first is
|
|
1222
|
+
// a governed meaning; the other two are lower-trust representations of it.
|
|
1223
|
+
const supersedable = candidate.semanticObjectType === 'dimension'
|
|
1224
|
+
|| candidate.kind === 'sql_column';
|
|
1225
|
+
if (!supersedable)
|
|
1226
|
+
return true;
|
|
1227
|
+
// A column's qualified identity points at its PARENT RELATION, so
|
|
1228
|
+
// `candidateLeafName` yields "customers" for `customers.customer_type`.
|
|
1229
|
+
// Match the candidate's own name as well, or a raw column is never
|
|
1230
|
+
// recognised as the field a block already publishes.
|
|
1231
|
+
const leaves = [
|
|
1232
|
+
normalizeMetricPhrase(String(candidate.name ?? '').split(/[.:/]/).at(-1) ?? ''),
|
|
1233
|
+
normalizeMetricPhrase(candidateLeafName(candidate)),
|
|
1234
|
+
].filter(Boolean);
|
|
1235
|
+
return !leaves.some((leaf) => certifiedCoverage.some((outputs) => outputs.has(leaf)));
|
|
1236
|
+
});
|
|
1237
|
+
return [...survivingPassthrough, ...representatives.values()].sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id));
|
|
1004
1238
|
}
|
|
1005
1239
|
/**
|
|
1006
1240
|
* The governed meaning to run when nothing proved a single exact reading.
|
|
@@ -1112,9 +1346,60 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
|
|
|
1112
1346
|
.sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id))
|
|
1113
1347
|
.slice(0, 3);
|
|
1114
1348
|
if (alternatives.length === 0) {
|
|
1115
|
-
|
|
1349
|
+
// Bare rankings need a measure choice, not a dimension gap. Retain the
|
|
1350
|
+
// no-options escape hatch for BCM-like retrieval failures.
|
|
1351
|
+
if (asksForRanking && !hasExplicitRankingMetric) {
|
|
1352
|
+
return bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
|
|
1353
|
+
}
|
|
1354
|
+
// Parsed-intent hints can include inherited/default dimensions that the
|
|
1355
|
+
// user never asked for. Only turn a missing field into a product-facing
|
|
1356
|
+
// modeling gap when its wording is present in this turn; otherwise let
|
|
1357
|
+
// bounded meaning resolution preserve its own ambiguity contract.
|
|
1358
|
+
const normalizedQuestion = normalizeMetricPhrase(request.question);
|
|
1359
|
+
if (!missingDimensions.every((dimension) => normalizedQuestion.includes(dimension)))
|
|
1116
1360
|
return undefined;
|
|
1117
|
-
|
|
1361
|
+
const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
|
|
1362
|
+
const message = `The requested dimension ${requestedLabel} is not modeled in the certified blocks, semantic model, dbt manifest, or runtime schema searched for this question. Add or map ${requestedLabel} before retrying.`;
|
|
1363
|
+
return {
|
|
1364
|
+
...base,
|
|
1365
|
+
action: 'block',
|
|
1366
|
+
confidence: 1,
|
|
1367
|
+
reason: message,
|
|
1368
|
+
source: 'heuristic',
|
|
1369
|
+
category: 'data_lookup',
|
|
1370
|
+
depth: 'quick',
|
|
1371
|
+
requiresClarification: false,
|
|
1372
|
+
retrievalEvidence,
|
|
1373
|
+
terminalOutcome: {
|
|
1374
|
+
kind: 'modeling_gap',
|
|
1375
|
+
code: 'ANALYTICAL_MODELING_GAP',
|
|
1376
|
+
message,
|
|
1377
|
+
candidateIds: [],
|
|
1378
|
+
},
|
|
1379
|
+
meaningResolution: {
|
|
1380
|
+
interpretedQuestion: request.question,
|
|
1381
|
+
questionType: questionTypeFromText(request.question),
|
|
1382
|
+
selectedConceptIds: [],
|
|
1383
|
+
queryIntent: {
|
|
1384
|
+
...defaultQueryIntent(evidence),
|
|
1385
|
+
measures: evidence.parsedIntent?.measures ?? [],
|
|
1386
|
+
dimensions: evidence.parsedIntent?.dimensions ?? [],
|
|
1387
|
+
filters: evidence.parsedIntent?.filters ?? [],
|
|
1388
|
+
},
|
|
1389
|
+
rejectedCandidates: [],
|
|
1390
|
+
confidence: 'low',
|
|
1391
|
+
missingInformation: [message],
|
|
1392
|
+
recommendedRoute: 'clarify',
|
|
1393
|
+
compatibilityOutcome: 'modeling_gap',
|
|
1394
|
+
compatibilityFailures: missingDimensions.map((dimension) => ({
|
|
1395
|
+
code: 'MISSING_DIMENSION',
|
|
1396
|
+
field: dimension,
|
|
1397
|
+
message: `${dimension} is not modeled.`,
|
|
1398
|
+
candidateIds: [],
|
|
1399
|
+
})),
|
|
1400
|
+
},
|
|
1401
|
+
resolvedAnalyticalPlan: undefined,
|
|
1402
|
+
};
|
|
1118
1403
|
}
|
|
1119
1404
|
const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
|
|
1120
1405
|
const alternativeLabels = alternatives.map(renderCandidateChoice);
|
|
@@ -1137,11 +1422,187 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
|
|
|
1137
1422
|
};
|
|
1138
1423
|
}
|
|
1139
1424
|
if (asksForRanking && !hasExplicitRankingMetric) {
|
|
1140
|
-
return bareRankingClarification(base, retrievalEvidence);
|
|
1425
|
+
return bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
|
|
1141
1426
|
}
|
|
1142
1427
|
return undefined;
|
|
1143
1428
|
}
|
|
1144
|
-
|
|
1429
|
+
/**
|
|
1430
|
+
* A clarification with NO selectable options is unanswerable, and asking it is a
|
|
1431
|
+
* dead end rather than a safety measure.
|
|
1432
|
+
*
|
|
1433
|
+
* Reported from production: "who are the top customers for BCM" returned
|
|
1434
|
+
* "Top by which governed metric?" with zero choices. Answering it in prose
|
|
1435
|
+
* ("...who have top revenue") produced the IDENTICAL question again, because the
|
|
1436
|
+
* reply carries no `selectedEvidenceId` and re-enters the same path with the
|
|
1437
|
+
* same evidence. A question that can only be answered by clicking a button that
|
|
1438
|
+
* was never rendered loops forever.
|
|
1439
|
+
*
|
|
1440
|
+
* When the option list is empty the failure is in RETRIEVAL, not in the user's
|
|
1441
|
+
* phrasing, so continue into the review-required generated lane and let the
|
|
1442
|
+
* answer carry the caveat. `requiresClarification` is cleared deliberately:
|
|
1443
|
+
* leaving it set would make `answerAnywayRoute` treat this as material ambiguity
|
|
1444
|
+
* and re-block the turn.
|
|
1445
|
+
*/
|
|
1446
|
+
function unanswerableClarificationFallback(base, retrievalEvidence, reason) {
|
|
1447
|
+
return {
|
|
1448
|
+
...base,
|
|
1449
|
+
action: 'answer',
|
|
1450
|
+
confidence: Math.min(base.confidence, 0.5),
|
|
1451
|
+
source: 'heuristic',
|
|
1452
|
+
category: 'data_lookup',
|
|
1453
|
+
requiresClarification: false,
|
|
1454
|
+
clarifyingQuestion: undefined,
|
|
1455
|
+
clarificationOptions: undefined,
|
|
1456
|
+
reason,
|
|
1457
|
+
...(retrievalEvidence ? { retrievalEvidence } : {}),
|
|
1458
|
+
resolvedAnalyticalPlan: undefined,
|
|
1459
|
+
meaningResolution: undefined,
|
|
1460
|
+
};
|
|
1461
|
+
}
|
|
1462
|
+
/**
|
|
1463
|
+
* "Top by which governed metric?" with NO choices is a dead end: the asker
|
|
1464
|
+
* cannot know which measures are both governed and valid at the ranked grain,
|
|
1465
|
+
* so the only move left is to guess. A built-CLI run on the commerce fixture
|
|
1466
|
+
* ended here with zero options while `revenue`, `lifetime_spend_pretax`, and
|
|
1467
|
+
* `orders` were all modeled.
|
|
1468
|
+
*
|
|
1469
|
+
* The question stays exactly as it was — this only attaches the compatible
|
|
1470
|
+
* ranking measures as selectable choices, minus any same-grain entity count,
|
|
1471
|
+
* which is degenerate for ranking individuals. Selecting one returns an
|
|
1472
|
+
* explicit qualified id, which takes the resolved-selection path instead of
|
|
1473
|
+
* asking again.
|
|
1474
|
+
*
|
|
1475
|
+
* Acceptance: AGT-030.
|
|
1476
|
+
*/
|
|
1477
|
+
/**
|
|
1478
|
+
* Generic measure vocabulary: words that say HOW MUCH, never WHICH SUBSET.
|
|
1479
|
+
* A candidate built only from these adds no scope the asker did not state.
|
|
1480
|
+
*/
|
|
1481
|
+
const GENERIC_MEASURE_TOKENS = new Set([
|
|
1482
|
+
'revenue', 'spend', 'spending', 'sale', 'amount', 'value', 'total', 'sum',
|
|
1483
|
+
'count', 'number', 'order', 'quantity', 'qty', 'price', 'cost', 'profit',
|
|
1484
|
+
'margin', 'gross', 'net', 'lifetime', 'avg', 'average', 'mean', 'median', 'score',
|
|
1485
|
+
'rate', 'ratio', 'percent', 'share', 'volume', 'unit', 'balance', 'pretax', 'ltv',
|
|
1486
|
+
'top', 'rank', 'ranking', 'by', 'per', 'the', 'of', 'and',
|
|
1487
|
+
]);
|
|
1488
|
+
/**
|
|
1489
|
+
* Crude, symmetric singularization. Applied to BOTH sides, so the only thing
|
|
1490
|
+
* that matters is that it agrees with itself — `address` becoming `addres` is
|
|
1491
|
+
* harmless when the question's `address` becomes `addres` too. Without it a
|
|
1492
|
+
* candidate named `customers.customer_value` is rejected against the question
|
|
1493
|
+
* "who are the top customers", because the singular `customer` is neither a
|
|
1494
|
+
* question word nor generic measure vocabulary. That is a morphology accident,
|
|
1495
|
+
* not an unrequested scope, and it silently refused good assumptions.
|
|
1496
|
+
*/
|
|
1497
|
+
function singularize(token) {
|
|
1498
|
+
if (token.length > 3 && token.endsWith('ies'))
|
|
1499
|
+
return `${token.slice(0, -3)}y`;
|
|
1500
|
+
if (token.length > 3 && (token.endsWith('ses') || token.endsWith('xes') || token.endsWith('zes'))) {
|
|
1501
|
+
return token.slice(0, -2);
|
|
1502
|
+
}
|
|
1503
|
+
if (token.length > 2 && token.endsWith('s') && !token.endsWith('ss'))
|
|
1504
|
+
return token.slice(0, -1);
|
|
1505
|
+
return token;
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Pick the measure a bare ranking should assume, or nothing.
|
|
1509
|
+
*
|
|
1510
|
+
* Returns the CANDIDATE, not a decision, because the assumption is only useful
|
|
1511
|
+
* where it can be bound: an `action: 'answer'` with no frozen analytical plan is
|
|
1512
|
+
* refused by the plan boundary and reaches the user as `blocked` with no
|
|
1513
|
+
* options, which is strictly worse than the clarification it replaced. Measured
|
|
1514
|
+
* exactly that way before this was moved to a caller that can bind it.
|
|
1515
|
+
*/
|
|
1516
|
+
export function assumableRankingMeasure(question, candidates) {
|
|
1517
|
+
// Only a MEASURE can be assumed. A certified block is a whole authored query,
|
|
1518
|
+
// not a ranking measure, so treating one as the answer to "by which metric?"
|
|
1519
|
+
// silently selects someone else's entire analysis.
|
|
1520
|
+
const assumable = candidates.filter((candidate) => (candidate.kind === 'semantic_metric' || candidate.kind === 'semantic_member')
|
|
1521
|
+
&& candidate.compatibility !== 'incompatible'
|
|
1522
|
+
&& rankingCandidateFitsBareQuestion(question, candidate));
|
|
1523
|
+
if (assumable.length === 0)
|
|
1524
|
+
return undefined;
|
|
1525
|
+
const assumption = assumeDominantCandidate({
|
|
1526
|
+
about: 'metric',
|
|
1527
|
+
candidates: assumable.map((candidate) => ({
|
|
1528
|
+
id: candidate.qualifiedId ?? candidate.id,
|
|
1529
|
+
label: candidate.name,
|
|
1530
|
+
score: candidate.relevanceScore,
|
|
1531
|
+
})),
|
|
1532
|
+
because: (chosen) => `"${chosen.label ?? chosen.id}" was the highest-ranked governed measure that can rank this entity, and it adds no filter the question did not ask for.`,
|
|
1533
|
+
});
|
|
1534
|
+
if (!assumption)
|
|
1535
|
+
return undefined;
|
|
1536
|
+
const chosen = assumable.find((candidate) => (candidate.qualifiedId ?? candidate.id) === assumption.chose);
|
|
1537
|
+
return chosen ? { candidate: chosen, assumption } : undefined;
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Does this ranking measure fit a question that named no measure?
|
|
1541
|
+
*
|
|
1542
|
+
* "Top customers" must not be silently answered by `top_beverage_customers`:
|
|
1543
|
+
* that candidate carries a scope — beverages — the asker never asked for, and
|
|
1544
|
+
* answering with it returns a confidently wrong list under a different question
|
|
1545
|
+
* than the one posed. `assumeDominantCandidate` deliberately leaves this check
|
|
1546
|
+
* to the caller, because dominance is about ranking and this is about meaning.
|
|
1547
|
+
*
|
|
1548
|
+
* The rule: every substantive token in the candidate's name must be either
|
|
1549
|
+
* something the question already said, or generic measure vocabulary. Any
|
|
1550
|
+
* leftover token is an unrequested qualifier, and the turn keeps asking.
|
|
1551
|
+
*/
|
|
1552
|
+
export function rankingCandidateFitsBareQuestion(question, candidate) {
|
|
1553
|
+
const questionTokens = new Set(substantiveLexicalTokens(question).map(singularize));
|
|
1554
|
+
// Identities arrive source-qualified (`semantic:metric:orders.revenue`). The
|
|
1555
|
+
// `semantic`/`metric` prefix is plumbing, not vocabulary — tokenizing it would
|
|
1556
|
+
// make every candidate look scoped and refuse every assumption.
|
|
1557
|
+
const identity = (candidate.qualifiedId ?? candidate.id ?? '').split(':').at(-1) ?? '';
|
|
1558
|
+
const nameTokens = substantiveLexicalTokens([candidate.name, identity].join(' '));
|
|
1559
|
+
if (nameTokens.length === 0)
|
|
1560
|
+
return false;
|
|
1561
|
+
return nameTokens.map(singularize).every((token) => questionTokens.has(token) || GENERIC_MEASURE_TOKENS.has(token));
|
|
1562
|
+
}
|
|
1563
|
+
function bareRankingClarification(base, retrievalEvidence, question, evidence, candidates,
|
|
1564
|
+
/**
|
|
1565
|
+
* May this turn settle the measure by assumption rather than by asking?
|
|
1566
|
+
* False wherever the step whose job is to judge ambiguity has positively
|
|
1567
|
+
* reported some — assuming past a finding overrides it rather than filling a
|
|
1568
|
+
* gap, and AGT-017 already establishes that lexical rank alone must not
|
|
1569
|
+
* settle meaning when semantic judgment is unavailable.
|
|
1570
|
+
*/
|
|
1571
|
+
mayAssume = true) {
|
|
1572
|
+
const rankingChoices = (candidates ?? []).filter((candidate) => {
|
|
1573
|
+
if (candidate.compatibility === 'incompatible')
|
|
1574
|
+
return false;
|
|
1575
|
+
if (candidate.kind !== 'certified_block'
|
|
1576
|
+
&& candidate.kind !== 'semantic_metric'
|
|
1577
|
+
&& candidate.kind !== 'semantic_member')
|
|
1578
|
+
return false;
|
|
1579
|
+
// Check BOTH identities: `qualifiedId` is often the bare semantic-layer
|
|
1580
|
+
// name, so testing it alone let `semantic:model:customers` through.
|
|
1581
|
+
const identities = [candidate.id, candidate.qualifiedId ?? ''].filter(Boolean);
|
|
1582
|
+
// A model, entity, dimension, dbt node, or warehouse table cannot BE the
|
|
1583
|
+
// measure a ranking is ordered by; offering one as a "governed metric" is
|
|
1584
|
+
// how `semantic:model:customers` reached the choice list.
|
|
1585
|
+
if (identities.some((identity) => /^(semantic:(model|entity|dimension|time_dimension):|dbt:|warehouse:)/i.test(identity)))
|
|
1586
|
+
return false;
|
|
1587
|
+
// `semantic:measure:X.X` is a count of X reported at X's own grain — every
|
|
1588
|
+
// row scores 1, so it can never order X. The metadata-driven guard below
|
|
1589
|
+
// needs a declared aggregation, which retrieval does not always carry, so
|
|
1590
|
+
// this identity check catches the case that metadata misses.
|
|
1591
|
+
const degenerateIdentity = identities.some((identity) => {
|
|
1592
|
+
const measurePath = /^semantic:(?:measure|metric):(.+)$/i.exec(identity)?.[1] ?? '';
|
|
1593
|
+
const [owner, measureName] = measurePath.split('.');
|
|
1594
|
+
return Boolean(owner && measureName
|
|
1595
|
+
&& normalizeMetricPhrase(owner) === normalizeMetricPhrase(measureName));
|
|
1596
|
+
});
|
|
1597
|
+
if (degenerateIdentity)
|
|
1598
|
+
return false;
|
|
1599
|
+
if (question && evidence && isDegenerateRankingMetric(question, evidence, candidate))
|
|
1600
|
+
return false;
|
|
1601
|
+
return true;
|
|
1602
|
+
});
|
|
1603
|
+
if (rankingChoices.length === 0) {
|
|
1604
|
+
return unanswerableClarificationFallback(base, retrievalEvidence, 'No retrieved governed measure can rank this entity, so DQL continued into the review-required generated lane instead of asking a question with no selectable answer.');
|
|
1605
|
+
}
|
|
1145
1606
|
return {
|
|
1146
1607
|
...base,
|
|
1147
1608
|
action: 'clarify',
|
|
@@ -1153,6 +1614,7 @@ function bareRankingClarification(base, retrievalEvidence) {
|
|
|
1153
1614
|
requiresClarification: true,
|
|
1154
1615
|
clarifyingQuestion: 'Top by which governed metric?',
|
|
1155
1616
|
retrievalEvidence,
|
|
1617
|
+
clarificationOptions: buildClarificationOptions(rankingChoices),
|
|
1156
1618
|
resolvedAnalyticalPlan: undefined,
|
|
1157
1619
|
meaningResolution: undefined,
|
|
1158
1620
|
};
|
|
@@ -1432,6 +1894,7 @@ export function createHybridRouter(options = {}) {
|
|
|
1432
1894
|
const threshold = options.llmThreshold ?? DEFAULT_THRESHOLD;
|
|
1433
1895
|
const cacheSize = options.cacheSize ?? DEFAULT_CACHE_SIZE;
|
|
1434
1896
|
const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
1897
|
+
const requireMeaningCall = options.requireMeaningCallForNaturalLanguage ?? true;
|
|
1435
1898
|
const cache = new Map();
|
|
1436
1899
|
let tick = 0;
|
|
1437
1900
|
const now = options.now ?? (() => { tick += 1; return tick; });
|
|
@@ -1492,8 +1955,14 @@ export function createHybridRouter(options = {}) {
|
|
|
1492
1955
|
// A structured clarification selection is authoritative identity input,
|
|
1493
1956
|
// not a new fuzzy-search phrase. Keep it in the bounded package even if
|
|
1494
1957
|
// per-tier limits would otherwise trim it from a large catalog.
|
|
1958
|
+
// Look in BOTH lists. The ranking-measure choices are supplemental
|
|
1959
|
+
// clarification candidates, not execution candidates, so resolving the
|
|
1960
|
+
// selection against `candidates` alone silently found nothing — the
|
|
1961
|
+
// click looked identical to no click, the ranking gate fired again, and
|
|
1962
|
+
// the same three options came back forever.
|
|
1495
1963
|
const selectedEvidence = request.selectedEvidenceId
|
|
1496
|
-
? evidence.candidates
|
|
1964
|
+
? [...evidence.candidates, ...(evidence.clarificationCandidates ?? [])]
|
|
1965
|
+
.find((candidate) => candidate.id === request.selectedEvidenceId && candidate.eligible !== false)
|
|
1497
1966
|
: undefined;
|
|
1498
1967
|
if (selectedEvidence && !candidates.some((candidate) => candidate.id === selectedEvidence.id)) {
|
|
1499
1968
|
candidates = [selectedEvidence, ...candidates.filter((candidate) => candidate.id !== selectedEvidence.id)]
|
|
@@ -1507,33 +1976,69 @@ export function createHybridRouter(options = {}) {
|
|
|
1507
1976
|
...evidence.candidates,
|
|
1508
1977
|
...(evidence.clarificationCandidates ?? []),
|
|
1509
1978
|
].filter((candidate, index, all) => candidate.eligible !== false && all.findIndex((other) => other.id === candidate.id) === index);
|
|
1510
|
-
const deterministicClarification = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
|
|
1511
|
-
if (deterministicClarification)
|
|
1512
|
-
return deterministicClarification;
|
|
1513
1979
|
const explicit = selectedEvidence ?? findExplicitEvidenceReference(request.question, candidates);
|
|
1514
|
-
|
|
1980
|
+
const explicitMeaningBinding = Boolean(explicit && (request.selectedEvidenceId
|
|
1981
|
+
|| /@(metric|block|model|table|column)\(/i.test(request.question)));
|
|
1982
|
+
const shouldUseMeaningCall = requireMeaningCall
|
|
1983
|
+
&& !explicitMeaningBinding
|
|
1984
|
+
&& Boolean(options.resolveMeaning || options.complete);
|
|
1985
|
+
// A normal natural-language turn must be interpreted against the
|
|
1986
|
+
// candidate cards before a deterministic clarification is allowed.
|
|
1987
|
+
// Running this gate first was the source of the "Top by which
|
|
1988
|
+
// governed metric?" repeat loop: it treated a customer-count
|
|
1989
|
+
// execution shim as the answer and never let the meaning model see
|
|
1990
|
+
// the ranking entity/measure distinction.
|
|
1991
|
+
if (!shouldUseMeaningCall && !explicitMeaningBinding) {
|
|
1992
|
+
const deterministicClarification = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
|
|
1993
|
+
if (deterministicClarification)
|
|
1994
|
+
return deterministicClarification;
|
|
1995
|
+
}
|
|
1996
|
+
if (explicit
|
|
1997
|
+
&& explicit.compatibility !== "incompatible"
|
|
1998
|
+
&& (!shouldUseMeaningCall || explicitMeaningBinding)) {
|
|
1999
|
+
if (isDegenerateRankingMetric(request.question, evidence, explicit)
|
|
2000
|
+
&& !hasExplicitRankingMeasure(request.question, evidence)) {
|
|
2001
|
+
return rankingMetricChoiceDecision(base, evidence, candidates, explicit, request.question);
|
|
2002
|
+
}
|
|
1515
2003
|
const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
1516
2004
|
return selectedEvidence && decision.requiresClarification
|
|
1517
2005
|
? continueCascadeAfterIncompleteSelection(base, evidence, candidates, selectedEvidence)
|
|
1518
2006
|
: decision;
|
|
1519
2007
|
}
|
|
1520
|
-
const multiMetricPrimary =
|
|
2008
|
+
const multiMetricPrimary = !shouldUseMeaningCall
|
|
2009
|
+
? exactMultiMetricPrimary(request.question, evidence, candidates)
|
|
2010
|
+
: undefined;
|
|
1521
2011
|
if (multiMetricPrimary) {
|
|
1522
2012
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
1523
2013
|
}
|
|
2014
|
+
// THE FAST LANE. Evaluated regardless of `shouldUseMeaningCall`, so an
|
|
2015
|
+
// exact certified hit short-circuits BEFORE the ~10s meaning call
|
|
2016
|
+
// rather than paying for it. Previously this shortcut only applied
|
|
2017
|
+
// when the call was already being skipped, which meant a perfect
|
|
2018
|
+
// certified match — the cheapest, most certain answer DQL can give —
|
|
2019
|
+
// was also one of the slowest.
|
|
2020
|
+
//
|
|
2021
|
+
// The precondition is deliberately the strictest one available:
|
|
2022
|
+
// exactly one certified block, compatible, whose AUTHORED EXAMPLE the
|
|
2023
|
+
// question matches. `meaning-evidence.ts` sets that flag only for an
|
|
2024
|
+
// authored-example fit and explicitly notes it is the one signal that
|
|
2025
|
+
// may grant this shortcut — lexical equality to a block name does not
|
|
2026
|
+
// qualify.
|
|
1524
2027
|
const authoredExample = authoritativeExactCertifiedExample(candidates);
|
|
1525
2028
|
if (authoredExample) {
|
|
1526
2029
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, authoredExample, candidates), 'heuristic', request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
1527
2030
|
}
|
|
1528
|
-
const exactCompatible = candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible");
|
|
2031
|
+
const exactCompatible = !shouldUseMeaningCall ? candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible") : [];
|
|
1529
2032
|
if (exactCompatible.length === 1 && !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
|
|
1530
2033
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
1531
2034
|
}
|
|
1532
|
-
const dominant =
|
|
2035
|
+
const dominant = !shouldUseMeaningCall
|
|
2036
|
+
? dominantCompatibleGovernedCandidate(candidates)
|
|
2037
|
+
: undefined;
|
|
1533
2038
|
if (dominant) {
|
|
1534
2039
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, dominant, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
1535
2040
|
}
|
|
1536
|
-
if (shouldDeferCompositionalFollowUpToExecutor(base, candidates)) {
|
|
2041
|
+
if (!shouldUseMeaningCall && shouldDeferCompositionalFollowUpToExecutor(base, candidates)) {
|
|
1537
2042
|
return routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative');
|
|
1538
2043
|
}
|
|
1539
2044
|
const key = cacheKey(request, evidence);
|
|
@@ -1577,7 +2082,73 @@ export function createHybridRouter(options = {}) {
|
|
|
1577
2082
|
if (resolution) {
|
|
1578
2083
|
const validated = validateMeaningResolution(resolution, candidates);
|
|
1579
2084
|
if (validated.ok) {
|
|
1580
|
-
|
|
2085
|
+
const safeResolution = preventDegenerateRankingResolution(validated.resolution, evidence, candidates, request.question);
|
|
2086
|
+
// Meaning interpretation is still required for a fresh turn,
|
|
2087
|
+
// but it cannot invent a ranking measure when the user only
|
|
2088
|
+
// supplied an entity. Preserve the precise follow-up after the
|
|
2089
|
+
// bounded call so this does not regress into a generic block
|
|
2090
|
+
// or a repeated customer-count answer.
|
|
2091
|
+
//
|
|
2092
|
+
// Gate on the MEANING MODEL's classification, not on
|
|
2093
|
+
// `questionTypeFromText`. The text heuristic only looks for
|
|
2094
|
+
// words like "top", so it also claimed "what region does the
|
|
2095
|
+
// top customer belong to" (an attribute lookup) and "top
|
|
2096
|
+
// products in Philadelphia and the customers who bought them"
|
|
2097
|
+
// (a compound turn) — both were preempted here and never
|
|
2098
|
+
// routed, even though the bounded call had just resolved them.
|
|
2099
|
+
//
|
|
2100
|
+
// A resolution that named an execution target is honored:
|
|
2101
|
+
// `preventDegenerateRankingResolution` above has already
|
|
2102
|
+
// downgraded a same-grain entity count to `clarify`, so
|
|
2103
|
+
// anything still standing is a governed measure the model
|
|
2104
|
+
// selected from qualified candidate ids.
|
|
2105
|
+
const resolutionResolvedRanking = safeResolution.recommendedRoute !== 'clarify'
|
|
2106
|
+
&& Boolean(safeResolution.recommendedExecutionId
|
|
2107
|
+
|| safeResolution.selectedConceptIds.length > 0);
|
|
2108
|
+
// An explicit SELECTION answers this gate as well as words in
|
|
2109
|
+
// the question do. `hasExplicitRankingMeasure` reads the
|
|
2110
|
+
// question TEXT, and clicking a choice never changes the text —
|
|
2111
|
+
// so picking `customers.average_order_value` re-asked "Top by
|
|
2112
|
+
// which governed metric?" with the same three options, forever.
|
|
2113
|
+
// A degenerate pick is still refused above, so anything
|
|
2114
|
+
// arriving here is a measure the reader chose from governed
|
|
2115
|
+
// evidence.
|
|
2116
|
+
const explicitRankingSelection = Boolean(request.selectedEvidenceId)
|
|
2117
|
+
&& Boolean(selectedEvidence)
|
|
2118
|
+
&& !isDegenerateRankingMetric(request.question, evidence, selectedEvidence);
|
|
2119
|
+
if (safeResolution.questionType === 'ranking'
|
|
2120
|
+
&& !hasExplicitRankingMeasure(request.question, evidence)
|
|
2121
|
+
&& !resolutionResolvedRanking
|
|
2122
|
+
&& !explicitRankingSelection) {
|
|
2123
|
+
// Meaning resolution ran here. If it named the ranking
|
|
2124
|
+
// measure as the missing piece, that is its judgment and the
|
|
2125
|
+
// turn asks rather than guessing past it. Otherwise a clearly
|
|
2126
|
+
// indicated measure is assumed and BOUND through the same
|
|
2127
|
+
// resolution path an explicit selection takes — an assumption
|
|
2128
|
+
// that cannot freeze a plan is refused downstream and reaches
|
|
2129
|
+
// the reader as `blocked` with no options.
|
|
2130
|
+
const resolverFlagged = (safeResolution.missingInformation ?? [])
|
|
2131
|
+
.some((item) => /measure|metric/i.test(item));
|
|
2132
|
+
const assumedRanking = resolverFlagged
|
|
2133
|
+
? undefined
|
|
2134
|
+
: assumableRankingMeasure(request.question, clarificationCandidates);
|
|
2135
|
+
if (assumedRanking) {
|
|
2136
|
+
return {
|
|
2137
|
+
...routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, assumedRanking.candidate, candidates), 'heuristic', request.question),
|
|
2138
|
+
assumptions: [assumedRanking.assumption],
|
|
2139
|
+
};
|
|
2140
|
+
}
|
|
2141
|
+
return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence,
|
|
2142
|
+
// Supplemental clarification cards carry the ranking
|
|
2143
|
+
// measures for the requested entity, which the execution
|
|
2144
|
+
// candidate set deliberately does not.
|
|
2145
|
+
clarificationCandidates, !resolverFlagged);
|
|
2146
|
+
}
|
|
2147
|
+
const deterministicGap = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
|
|
2148
|
+
if (deterministicGap && safeResolution.recommendedRoute === 'clarify') {
|
|
2149
|
+
return deterministicGap;
|
|
2150
|
+
}
|
|
2151
|
+
return remember(key, routeDecisionForResolution(base, evidence, candidates, safeResolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative'));
|
|
1581
2152
|
}
|
|
1582
2153
|
const invalidResolution = {
|
|
1583
2154
|
interpretedQuestion: request.question,
|
|
@@ -1610,7 +2181,15 @@ export function createHybridRouter(options = {}) {
|
|
|
1610
2181
|
// retrieval signal or permitting a general-knowledge misroute.
|
|
1611
2182
|
meaningResolverReachable = false;
|
|
1612
2183
|
}
|
|
1613
|
-
|
|
2184
|
+
const fallbackDecision = routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative', meaningResolverReachable);
|
|
2185
|
+
if (!shouldUseMeaningCall)
|
|
2186
|
+
return fallbackDecision;
|
|
2187
|
+
// The provider was unavailable or returned malformed JSON. Apply the
|
|
2188
|
+
// deterministic clarification only after the bounded meaning attempt
|
|
2189
|
+
// has been exhausted; this preserves a precise recovery path without
|
|
2190
|
+
// allowing the generic governed error to terminate the question.
|
|
2191
|
+
return deterministicPrePlanClarification(request, base, evidence, clarificationCandidates)
|
|
2192
|
+
?? fallbackDecision;
|
|
1614
2193
|
}
|
|
1615
2194
|
}
|
|
1616
2195
|
// Legacy/no-evidence path. A confident analytical heuristic stays offline;
|