@duckcodeailabs/dql-agent 1.13.5 → 1.14.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 +21 -4
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +135 -29
- package/dist/agent-run-engine.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/analytical-orchestration.d.ts +256 -0
- package/dist/analytical-orchestration.d.ts.map +1 -0
- package/dist/analytical-orchestration.js +419 -0
- package/dist/analytical-orchestration.js.map +1 -0
- package/dist/analytical-result-facts.d.ts +1 -1
- package/dist/analytical-result-facts.d.ts.map +1 -1
- package/dist/analytical-result-facts.js +39 -0
- package/dist/analytical-result-facts.js.map +1 -1
- package/dist/answer-loop.d.ts +4 -0
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +95 -6
- package/dist/answer-loop.js.map +1 -1
- package/dist/conversation/snapshot.d.ts.map +1 -1
- package/dist/conversation/snapshot.js +3 -0
- 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/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.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/catalog.d.ts +24 -0
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +227 -42
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/meaning-evidence.d.ts.map +1 -1
- package/dist/metadata/meaning-evidence.js +86 -1
- package/dist/metadata/meaning-evidence.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 +8 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +368 -22
- package/dist/router.js.map +1 -1
- package/package.json +4 -4
package/dist/router.js
CHANGED
|
@@ -793,6 +793,113 @@ function buildEvidenceClarification(candidates, missing = []) {
|
|
|
793
793
|
return `I found relevant governed context, but need ${missing.join(" and ")}. What should I use?`;
|
|
794
794
|
return "Which governed business meaning should I use for this question?";
|
|
795
795
|
}
|
|
796
|
+
/**
|
|
797
|
+
* A distinct-entity count is not a useful ranking measure at the same entity
|
|
798
|
+
* grain: every customer normally has a count of one. Keep the candidate in the
|
|
799
|
+
* evidence trace, but do not let lexical relevance freeze it as the answer to
|
|
800
|
+
* "top customers". This is deliberately a semantic suitability check, not a
|
|
801
|
+
* name-based ban; an explicit "top customers by customer count" request remains
|
|
802
|
+
* the user's choice and can proceed through normal compatibility checks.
|
|
803
|
+
*/
|
|
804
|
+
function isDegenerateRankingMetric(question, evidence, candidate) {
|
|
805
|
+
if (questionTypeFromText(question) !== 'ranking')
|
|
806
|
+
return false;
|
|
807
|
+
if (candidate.kind !== 'semantic_metric' && candidate.kind !== 'semantic_member')
|
|
808
|
+
return false;
|
|
809
|
+
const capability = normalizeEvidenceAnalyticalCapability(candidate).capability;
|
|
810
|
+
const aggregation = normalizeMetricPhrase(candidate.aggregation ?? capability?.aggregation ?? '');
|
|
811
|
+
if (!aggregation || !/^(count|count distinct|count unique|count distinct values)$/.test(aggregation))
|
|
812
|
+
return false;
|
|
813
|
+
const questionTerms = new Set(substantiveLexicalTokens(question));
|
|
814
|
+
const entityTerms = [
|
|
815
|
+
candidate.primaryEntity ?? '',
|
|
816
|
+
...(candidate.analyticalCapability?.resultGrainIds ?? []),
|
|
817
|
+
...(candidate.dimensions ?? []),
|
|
818
|
+
...(evidence.parsedIntent?.dimensions ?? []),
|
|
819
|
+
].flatMap((value) => substantiveLexicalTokens(value));
|
|
820
|
+
const metricTerms = [candidate.name, candidate.qualifiedId ?? '', ...(candidate.aliases ?? [])]
|
|
821
|
+
.flatMap((value) => substantiveLexicalTokens(value));
|
|
822
|
+
return entityTerms.some((term) => questionTerms.has(term))
|
|
823
|
+
&& metricTerms.some((term) => entityTerms.includes(term));
|
|
824
|
+
}
|
|
825
|
+
function hasExplicitRankingMeasure(question, evidence) {
|
|
826
|
+
const parsed = [
|
|
827
|
+
...(evidence.parsedIntent?.measures ?? []),
|
|
828
|
+
...extractRankingMeasurePhrases(question),
|
|
829
|
+
].map(normalizeMetricPhrase).filter(Boolean);
|
|
830
|
+
return parsed.length > 0;
|
|
831
|
+
}
|
|
832
|
+
function extractRankingMeasurePhrases(question) {
|
|
833
|
+
const matches = [];
|
|
834
|
+
for (const pattern of [
|
|
835
|
+
/\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,
|
|
836
|
+
/\b(?:highest|lowest|most|least)\s+([a-z][a-z0-9_. -]{1,80}?)(?=\s+(?:among|for each|per|in|where|during|over)|[?.!,]|$)/gi,
|
|
837
|
+
]) {
|
|
838
|
+
for (const match of question.matchAll(pattern))
|
|
839
|
+
if (match[1])
|
|
840
|
+
matches.push(match[1]);
|
|
841
|
+
}
|
|
842
|
+
return matches;
|
|
843
|
+
}
|
|
844
|
+
function rankingMetricChoiceDecision(base, evidence, candidates, selected, question) {
|
|
845
|
+
const options = candidates
|
|
846
|
+
.filter((candidate) => candidate.id !== selected.id
|
|
847
|
+
&& candidate.compatibility !== 'incompatible'
|
|
848
|
+
&& candidate.kind === 'semantic_metric'
|
|
849
|
+
&& !isDegenerateRankingMetric(question, evidence, candidate))
|
|
850
|
+
.slice(0, 3);
|
|
851
|
+
const labels = options.length > 0
|
|
852
|
+
? options.map((candidate) => renderCandidateChoice(candidate)).join(' or ')
|
|
853
|
+
: 'revenue, order count, or another measure available in the model';
|
|
854
|
+
return {
|
|
855
|
+
...base,
|
|
856
|
+
action: 'clarify',
|
|
857
|
+
confidence: 1,
|
|
858
|
+
source: 'heuristic',
|
|
859
|
+
category: 'unclear',
|
|
860
|
+
depth: 'quick',
|
|
861
|
+
followsUp: true,
|
|
862
|
+
requiresClarification: true,
|
|
863
|
+
reason: `${selected.name} counts customers; it cannot distinguish individual customers for a top-customer ranking.`,
|
|
864
|
+
clarifyingQuestion: `That metric counts unique customers and cannot rank individual customers. Which measure should rank them: ${labels}?`,
|
|
865
|
+
clarificationOptions: options.length > 0 ? buildClarificationOptions(options) : undefined,
|
|
866
|
+
retrievalEvidence: retrievalTrace(evidence, candidates),
|
|
867
|
+
resolvedAnalyticalPlan: undefined,
|
|
868
|
+
meaningResolution: undefined,
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
function preventDegenerateRankingResolution(resolution, evidence, candidates, question) {
|
|
872
|
+
if (hasExplicitRankingMeasure(question, evidence))
|
|
873
|
+
return resolution;
|
|
874
|
+
const selected = candidates.find((candidate) => candidate.id === resolution.recommendedExecutionId
|
|
875
|
+
|| resolution.selectedConceptIds.includes(candidate.id));
|
|
876
|
+
if (!selected || !isDegenerateRankingMetric(question, evidence, selected))
|
|
877
|
+
return resolution;
|
|
878
|
+
const alternatives = candidates
|
|
879
|
+
.filter((candidate) => candidate.id !== selected.id
|
|
880
|
+
&& candidate.kind === 'semantic_metric'
|
|
881
|
+
&& candidate.compatibility !== 'incompatible'
|
|
882
|
+
&& !isDegenerateRankingMetric(question, evidence, candidate))
|
|
883
|
+
.slice(0, 3);
|
|
884
|
+
const alternativeLabels = alternatives.map(renderCandidateChoice).join(' or ');
|
|
885
|
+
return {
|
|
886
|
+
...resolution,
|
|
887
|
+
confidence: 'low',
|
|
888
|
+
recommendedRoute: 'clarify',
|
|
889
|
+
recommendedExecutionId: undefined,
|
|
890
|
+
selectedConceptIds: [],
|
|
891
|
+
analyticalFrame: undefined,
|
|
892
|
+
missingInformation: [
|
|
893
|
+
...new Set([
|
|
894
|
+
...resolution.missingInformation,
|
|
895
|
+
`${selected.name} counts the ranked entity and is not a suitable ranking measure`,
|
|
896
|
+
]),
|
|
897
|
+
],
|
|
898
|
+
clarifyingQuestion: alternativeLabels
|
|
899
|
+
? `I found ${selected.name}, but it counts the ranked entity and cannot identify the top individual customers. Which measure should I use: ${alternativeLabels}?`
|
|
900
|
+
: `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?`,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
796
903
|
function directResolution(request, evidence, candidate, candidates) {
|
|
797
904
|
const inferredQuestionType = questionTypeFromText(request.question);
|
|
798
905
|
const questionType = inferredQuestionType === 'definition'
|
|
@@ -930,12 +1037,21 @@ mayAssumeInterpretation = true) {
|
|
|
930
1037
|
if (multiMetricPrimary) {
|
|
931
1038
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, planMode);
|
|
932
1039
|
}
|
|
933
|
-
const
|
|
1040
|
+
const rankingCandidates = hasExplicitRankingMeasure(request.question, evidence)
|
|
1041
|
+
? candidates
|
|
1042
|
+
: candidates.filter((candidate) => !isDegenerateRankingMetric(request.question, evidence, candidate));
|
|
1043
|
+
if (questionTypeFromText(request.question) === 'ranking'
|
|
1044
|
+
&& !hasExplicitRankingMeasure(request.question, evidence)) {
|
|
1045
|
+
return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence, rankingCandidates);
|
|
1046
|
+
}
|
|
1047
|
+
const exactCompatible = candidates.filter((candidate) => candidate.exactMatch
|
|
1048
|
+
&& candidate.compatibility !== "incompatible"
|
|
1049
|
+
&& rankingCandidates.includes(candidate));
|
|
934
1050
|
if (exactCompatible.length === 1 &&
|
|
935
1051
|
!hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
|
|
936
1052
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, planMode);
|
|
937
1053
|
}
|
|
938
|
-
const semanticMetric = uniqueExecutableSemanticMetric(evidence,
|
|
1054
|
+
const semanticMetric = uniqueExecutableSemanticMetric(evidence, rankingCandidates);
|
|
939
1055
|
if (semanticMetric) {
|
|
940
1056
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, semanticMetric, candidates), "heuristic", request.question, planMode);
|
|
941
1057
|
}
|
|
@@ -944,7 +1060,7 @@ mayAssumeInterpretation = true) {
|
|
|
944
1060
|
// `type: simple` metric, the measure it wraps, and the model that holds them
|
|
945
1061
|
// were offered as three competing "meanings" of the same number.
|
|
946
1062
|
const best = mayAssumeInterpretation
|
|
947
|
-
? bestGovernedInterpretation(request.question,
|
|
1063
|
+
? bestGovernedInterpretation(request.question, rankingCandidates)
|
|
948
1064
|
: undefined;
|
|
949
1065
|
if (best) {
|
|
950
1066
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, best, candidates), "heuristic", request.question, planMode);
|
|
@@ -981,11 +1097,67 @@ export function collapseRedundantGovernedCandidates(question, candidates) {
|
|
|
981
1097
|
const byScore = [...new Map(candidates
|
|
982
1098
|
.filter((candidate) => candidate.eligible !== false && candidate.compatibility !== 'incompatible')
|
|
983
1099
|
.map((candidate) => [candidate.id, candidate])).values()].sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id));
|
|
984
|
-
|
|
1100
|
+
// An entity is a JOIN KEY. It is a reading of "how many customers", but never
|
|
1101
|
+
// of "what customer type is <member>" — there the reader named a field and a
|
|
1102
|
+
// member, and the entity cannot answer either. The trigger used to be a
|
|
1103
|
+
// four-word list (name/label/title/description), so "customer type", "region",
|
|
1104
|
+
// and "when did X first order" all left the entity competing and turned an
|
|
1105
|
+
// ordinary attribute lookup into a bind-interrogation. Recognise the
|
|
1106
|
+
// interrogative FORM as well as the vocabulary; `hasDimension` below still
|
|
1107
|
+
// requires that a real attribute was actually retrieved.
|
|
1108
|
+
const wantsAttribute = /\b(names?|labels?|titles?|descriptions?|types?|status(?:es)?|categor(?:y|ies)|segments?|tiers?|regions?|emails?|addresses?)\b/i.test(question)
|
|
1109
|
+
|| /\b(what|which|when|where)\b[^?]*\b(is|are|was|were|does|do|did|belongs?)\b/i.test(question);
|
|
985
1110
|
const hasDimension = byScore.some((candidate) => candidate.semanticObjectType === 'dimension');
|
|
986
|
-
|
|
987
|
-
|
|
1111
|
+
// An entity arrives as a semantic-layer entity OR as a DQL modeling entity
|
|
1112
|
+
// (`dql:entity:…`, kind `dql_modeling`). Testing only `semanticObjectType`
|
|
1113
|
+
// left the DQL one competing, so the interrogation survived the fix above.
|
|
1114
|
+
// Check BOTH identities: a DQL entity's `qualifiedId` is the bare
|
|
1115
|
+
// `commerce::entity::customer`, so testing the qualified id alone still let
|
|
1116
|
+
// it through.
|
|
1117
|
+
const isEntityCandidate = (candidate) => candidate.semanticObjectType === 'entity'
|
|
1118
|
+
|| [candidate.id, candidate.qualifiedId ?? ''].some((identity) => /(^|:)entity(:|::)/i.test(identity));
|
|
1119
|
+
const entityFiltered = wantsAttribute && hasDimension
|
|
1120
|
+
? byScore.filter((candidate) => !isEntityCandidate(candidate))
|
|
988
1121
|
: byScore;
|
|
1122
|
+
// Within an attribute question, a candidate matching only a SUB-TOKEN of the
|
|
1123
|
+
// requested field is a lexical decoy, not a competing reading. "What customer
|
|
1124
|
+
// type is <member>?" dragged in `raw_products.type` and
|
|
1125
|
+
// `orders.new_customer_orders` purely because they contain "type" and
|
|
1126
|
+
// "customer", and two decoys are enough to trip the ambiguity gate and turn
|
|
1127
|
+
// the lookup into an interrogation. Score how much of the question each
|
|
1128
|
+
// candidate actually accounts for — including the fields a block declares,
|
|
1129
|
+
// which is how the block that OUTPUTS `customer_type` outranks a column
|
|
1130
|
+
// merely named `type` — and keep only the most specific matches.
|
|
1131
|
+
const normalizedQuestion = normalizeMetricPhrase(question);
|
|
1132
|
+
const phraseSpecificity = (candidate) => {
|
|
1133
|
+
const terms = [
|
|
1134
|
+
candidate.name,
|
|
1135
|
+
...(candidate.aliases ?? []),
|
|
1136
|
+
...(candidate.dimensions ?? []),
|
|
1137
|
+
...(candidate.compatibilityFacts ?? [])
|
|
1138
|
+
.filter((fact) => fact.startsWith('output: '))
|
|
1139
|
+
.map((fact) => fact.slice('output: '.length)),
|
|
1140
|
+
].map((term) => normalizeMetricPhrase(String(term ?? '').split(/[.:/]/).at(-1) ?? ''));
|
|
1141
|
+
let best = 0;
|
|
1142
|
+
for (const term of terms) {
|
|
1143
|
+
if (!term)
|
|
1144
|
+
continue;
|
|
1145
|
+
const matches = normalizedQuestion === term
|
|
1146
|
+
|| normalizedQuestion.startsWith(`${term} `)
|
|
1147
|
+
|| normalizedQuestion.endsWith(` ${term}`)
|
|
1148
|
+
|| normalizedQuestion.includes(` ${term} `);
|
|
1149
|
+
if (matches)
|
|
1150
|
+
best = Math.max(best, term.split(' ').length);
|
|
1151
|
+
}
|
|
1152
|
+
return best;
|
|
1153
|
+
};
|
|
1154
|
+
const specificity = new Map(entityFiltered.map((candidate) => [candidate.id, phraseSpecificity(candidate)]));
|
|
1155
|
+
const bestSpecificity = Math.max(0, ...specificity.values());
|
|
1156
|
+
// Only prune when something matched a MULTI-word field name. A single shared
|
|
1157
|
+
// token is not enough evidence to call the others decoys.
|
|
1158
|
+
const kindFiltered = wantsAttribute && hasDimension && bestSpecificity >= 2
|
|
1159
|
+
? entityFiltered.filter((candidate) => (specificity.get(candidate.id) ?? 0) === bestSpecificity)
|
|
1160
|
+
: entityFiltered;
|
|
989
1161
|
const representatives = new Map();
|
|
990
1162
|
const passthrough = [];
|
|
991
1163
|
for (const candidate of kindFiltered) {
|
|
@@ -1000,7 +1172,44 @@ export function collapseRedundantGovernedCandidates(question, candidates) {
|
|
|
1000
1172
|
representatives.set(key, candidate);
|
|
1001
1173
|
}
|
|
1002
1174
|
}
|
|
1003
|
-
|
|
1175
|
+
// A certified block that already OUTPUTS an attribute is not a competing
|
|
1176
|
+
// MEANING of that attribute — it is the same reading at higher authority.
|
|
1177
|
+
// Keeping both turned an ordinary attribute lookup ("what customer type is
|
|
1178
|
+
// <member>?") into a "Which governed meaning should DQL bind: customer_profile
|
|
1179
|
+
// or customers.customer_type?" interrogation, even though the block declares
|
|
1180
|
+
// that exact output, sits at the requested grain, and permits the member
|
|
1181
|
+
// filter. The cascade already says certified outranks semantic for one
|
|
1182
|
+
// reading; this stops the tie from being mistaken for ambiguity.
|
|
1183
|
+
const certifiedCoverage = passthrough
|
|
1184
|
+
.filter((candidate) => candidate.kind === 'certified_block')
|
|
1185
|
+
.map((candidate) => new Set([
|
|
1186
|
+
...(candidate.dimensions ?? []),
|
|
1187
|
+
...(candidate.compatibilityFacts ?? [])
|
|
1188
|
+
.filter((fact) => fact.startsWith('output: '))
|
|
1189
|
+
.map((fact) => fact.slice('output: '.length)),
|
|
1190
|
+
].map((value) => normalizeMetricPhrase(String(value).split(/[.:/]/).at(-1) ?? ''))
|
|
1191
|
+
.filter(Boolean)));
|
|
1192
|
+
const survivingPassthrough = certifiedCoverage.length === 0
|
|
1193
|
+
? passthrough
|
|
1194
|
+
: passthrough.filter((candidate) => {
|
|
1195
|
+
// The same field can arrive three ways — the block's declared output, the
|
|
1196
|
+
// semantic dimension, and the raw warehouse/dbt column. Only the first is
|
|
1197
|
+
// a governed meaning; the other two are lower-trust representations of it.
|
|
1198
|
+
const supersedable = candidate.semanticObjectType === 'dimension'
|
|
1199
|
+
|| candidate.kind === 'sql_column';
|
|
1200
|
+
if (!supersedable)
|
|
1201
|
+
return true;
|
|
1202
|
+
// A column's qualified identity points at its PARENT RELATION, so
|
|
1203
|
+
// `candidateLeafName` yields "customers" for `customers.customer_type`.
|
|
1204
|
+
// Match the candidate's own name as well, or a raw column is never
|
|
1205
|
+
// recognised as the field a block already publishes.
|
|
1206
|
+
const leaves = [
|
|
1207
|
+
normalizeMetricPhrase(String(candidate.name ?? '').split(/[.:/]/).at(-1) ?? ''),
|
|
1208
|
+
normalizeMetricPhrase(candidateLeafName(candidate)),
|
|
1209
|
+
].filter(Boolean);
|
|
1210
|
+
return !leaves.some((leaf) => certifiedCoverage.some((outputs) => outputs.has(leaf)));
|
|
1211
|
+
});
|
|
1212
|
+
return [...survivingPassthrough, ...representatives.values()].sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id));
|
|
1004
1213
|
}
|
|
1005
1214
|
/**
|
|
1006
1215
|
* The governed meaning to run when nothing proved a single exact reading.
|
|
@@ -1114,7 +1323,7 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
|
|
|
1114
1323
|
if (alternatives.length === 0) {
|
|
1115
1324
|
if (!asksForRanking || hasExplicitRankingMetric)
|
|
1116
1325
|
return undefined;
|
|
1117
|
-
return bareRankingClarification(base, retrievalEvidence);
|
|
1326
|
+
return bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
|
|
1118
1327
|
}
|
|
1119
1328
|
const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
|
|
1120
1329
|
const alternativeLabels = alternatives.map(renderCandidateChoice);
|
|
@@ -1137,11 +1346,57 @@ function deterministicPrePlanClarification(request, base, evidence, candidates)
|
|
|
1137
1346
|
};
|
|
1138
1347
|
}
|
|
1139
1348
|
if (asksForRanking && !hasExplicitRankingMetric) {
|
|
1140
|
-
return bareRankingClarification(base, retrievalEvidence);
|
|
1349
|
+
return bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
|
|
1141
1350
|
}
|
|
1142
1351
|
return undefined;
|
|
1143
1352
|
}
|
|
1144
|
-
|
|
1353
|
+
/**
|
|
1354
|
+
* "Top by which governed metric?" with NO choices is a dead end: the asker
|
|
1355
|
+
* cannot know which measures are both governed and valid at the ranked grain,
|
|
1356
|
+
* so the only move left is to guess. A built-CLI run on the commerce fixture
|
|
1357
|
+
* ended here with zero options while `revenue`, `lifetime_spend_pretax`, and
|
|
1358
|
+
* `orders` were all modeled.
|
|
1359
|
+
*
|
|
1360
|
+
* The question stays exactly as it was — this only attaches the compatible
|
|
1361
|
+
* ranking measures as selectable choices, minus any same-grain entity count,
|
|
1362
|
+
* which is degenerate for ranking individuals. Selecting one returns an
|
|
1363
|
+
* explicit qualified id, which takes the resolved-selection path instead of
|
|
1364
|
+
* asking again.
|
|
1365
|
+
*
|
|
1366
|
+
* Acceptance: AGT-030.
|
|
1367
|
+
*/
|
|
1368
|
+
function bareRankingClarification(base, retrievalEvidence, question, evidence, candidates) {
|
|
1369
|
+
const rankingChoices = (candidates ?? []).filter((candidate) => {
|
|
1370
|
+
if (candidate.compatibility === 'incompatible')
|
|
1371
|
+
return false;
|
|
1372
|
+
if (candidate.kind !== 'certified_block'
|
|
1373
|
+
&& candidate.kind !== 'semantic_metric'
|
|
1374
|
+
&& candidate.kind !== 'semantic_member')
|
|
1375
|
+
return false;
|
|
1376
|
+
// Check BOTH identities: `qualifiedId` is often the bare semantic-layer
|
|
1377
|
+
// name, so testing it alone let `semantic:model:customers` through.
|
|
1378
|
+
const identities = [candidate.id, candidate.qualifiedId ?? ''].filter(Boolean);
|
|
1379
|
+
// A model, entity, dimension, dbt node, or warehouse table cannot BE the
|
|
1380
|
+
// measure a ranking is ordered by; offering one as a "governed metric" is
|
|
1381
|
+
// how `semantic:model:customers` reached the choice list.
|
|
1382
|
+
if (identities.some((identity) => /^(semantic:(model|entity|dimension|time_dimension):|dbt:|warehouse:)/i.test(identity)))
|
|
1383
|
+
return false;
|
|
1384
|
+
// `semantic:measure:X.X` is a count of X reported at X's own grain — every
|
|
1385
|
+
// row scores 1, so it can never order X. The metadata-driven guard below
|
|
1386
|
+
// needs a declared aggregation, which retrieval does not always carry, so
|
|
1387
|
+
// this identity check catches the case that metadata misses.
|
|
1388
|
+
const degenerateIdentity = identities.some((identity) => {
|
|
1389
|
+
const measurePath = /^semantic:(?:measure|metric):(.+)$/i.exec(identity)?.[1] ?? '';
|
|
1390
|
+
const [owner, measureName] = measurePath.split('.');
|
|
1391
|
+
return Boolean(owner && measureName
|
|
1392
|
+
&& normalizeMetricPhrase(owner) === normalizeMetricPhrase(measureName));
|
|
1393
|
+
});
|
|
1394
|
+
if (degenerateIdentity)
|
|
1395
|
+
return false;
|
|
1396
|
+
if (question && evidence && isDegenerateRankingMetric(question, evidence, candidate))
|
|
1397
|
+
return false;
|
|
1398
|
+
return true;
|
|
1399
|
+
});
|
|
1145
1400
|
return {
|
|
1146
1401
|
...base,
|
|
1147
1402
|
action: 'clarify',
|
|
@@ -1153,6 +1408,9 @@ function bareRankingClarification(base, retrievalEvidence) {
|
|
|
1153
1408
|
requiresClarification: true,
|
|
1154
1409
|
clarifyingQuestion: 'Top by which governed metric?',
|
|
1155
1410
|
retrievalEvidence,
|
|
1411
|
+
...(rankingChoices.length > 0
|
|
1412
|
+
? { clarificationOptions: buildClarificationOptions(rankingChoices) }
|
|
1413
|
+
: {}),
|
|
1156
1414
|
resolvedAnalyticalPlan: undefined,
|
|
1157
1415
|
meaningResolution: undefined,
|
|
1158
1416
|
};
|
|
@@ -1432,6 +1690,7 @@ export function createHybridRouter(options = {}) {
|
|
|
1432
1690
|
const threshold = options.llmThreshold ?? DEFAULT_THRESHOLD;
|
|
1433
1691
|
const cacheSize = options.cacheSize ?? DEFAULT_CACHE_SIZE;
|
|
1434
1692
|
const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
1693
|
+
const requireMeaningCall = options.requireMeaningCallForNaturalLanguage ?? true;
|
|
1435
1694
|
const cache = new Map();
|
|
1436
1695
|
let tick = 0;
|
|
1437
1696
|
const now = options.now ?? (() => { tick += 1; return tick; });
|
|
@@ -1492,8 +1751,14 @@ export function createHybridRouter(options = {}) {
|
|
|
1492
1751
|
// A structured clarification selection is authoritative identity input,
|
|
1493
1752
|
// not a new fuzzy-search phrase. Keep it in the bounded package even if
|
|
1494
1753
|
// per-tier limits would otherwise trim it from a large catalog.
|
|
1754
|
+
// Look in BOTH lists. The ranking-measure choices are supplemental
|
|
1755
|
+
// clarification candidates, not execution candidates, so resolving the
|
|
1756
|
+
// selection against `candidates` alone silently found nothing — the
|
|
1757
|
+
// click looked identical to no click, the ranking gate fired again, and
|
|
1758
|
+
// the same three options came back forever.
|
|
1495
1759
|
const selectedEvidence = request.selectedEvidenceId
|
|
1496
|
-
? evidence.candidates
|
|
1760
|
+
? [...evidence.candidates, ...(evidence.clarificationCandidates ?? [])]
|
|
1761
|
+
.find((candidate) => candidate.id === request.selectedEvidenceId && candidate.eligible !== false)
|
|
1497
1762
|
: undefined;
|
|
1498
1763
|
if (selectedEvidence && !candidates.some((candidate) => candidate.id === selectedEvidence.id)) {
|
|
1499
1764
|
candidates = [selectedEvidence, ...candidates.filter((candidate) => candidate.id !== selectedEvidence.id)]
|
|
@@ -1507,33 +1772,58 @@ export function createHybridRouter(options = {}) {
|
|
|
1507
1772
|
...evidence.candidates,
|
|
1508
1773
|
...(evidence.clarificationCandidates ?? []),
|
|
1509
1774
|
].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
1775
|
const explicit = selectedEvidence ?? findExplicitEvidenceReference(request.question, candidates);
|
|
1514
|
-
|
|
1776
|
+
const explicitMeaningBinding = Boolean(explicit && (request.selectedEvidenceId
|
|
1777
|
+
|| /@(metric|block|model|table|column)\(/i.test(request.question)));
|
|
1778
|
+
const shouldUseMeaningCall = requireMeaningCall
|
|
1779
|
+
&& !explicitMeaningBinding
|
|
1780
|
+
&& Boolean(options.resolveMeaning || options.complete);
|
|
1781
|
+
// A normal natural-language turn must be interpreted against the
|
|
1782
|
+
// candidate cards before a deterministic clarification is allowed.
|
|
1783
|
+
// Running this gate first was the source of the "Top by which
|
|
1784
|
+
// governed metric?" repeat loop: it treated a customer-count
|
|
1785
|
+
// execution shim as the answer and never let the meaning model see
|
|
1786
|
+
// the ranking entity/measure distinction.
|
|
1787
|
+
if (!shouldUseMeaningCall && !explicitMeaningBinding) {
|
|
1788
|
+
const deterministicClarification = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
|
|
1789
|
+
if (deterministicClarification)
|
|
1790
|
+
return deterministicClarification;
|
|
1791
|
+
}
|
|
1792
|
+
if (explicit
|
|
1793
|
+
&& explicit.compatibility !== "incompatible"
|
|
1794
|
+
&& (!shouldUseMeaningCall || explicitMeaningBinding)) {
|
|
1795
|
+
if (isDegenerateRankingMetric(request.question, evidence, explicit)
|
|
1796
|
+
&& !hasExplicitRankingMeasure(request.question, evidence)) {
|
|
1797
|
+
return rankingMetricChoiceDecision(base, evidence, candidates, explicit, request.question);
|
|
1798
|
+
}
|
|
1515
1799
|
const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
1516
1800
|
return selectedEvidence && decision.requiresClarification
|
|
1517
1801
|
? continueCascadeAfterIncompleteSelection(base, evidence, candidates, selectedEvidence)
|
|
1518
1802
|
: decision;
|
|
1519
1803
|
}
|
|
1520
|
-
const multiMetricPrimary =
|
|
1804
|
+
const multiMetricPrimary = !shouldUseMeaningCall
|
|
1805
|
+
? exactMultiMetricPrimary(request.question, evidence, candidates)
|
|
1806
|
+
: undefined;
|
|
1521
1807
|
if (multiMetricPrimary) {
|
|
1522
1808
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
1523
1809
|
}
|
|
1524
|
-
const authoredExample =
|
|
1810
|
+
const authoredExample = !shouldUseMeaningCall
|
|
1811
|
+
? authoritativeExactCertifiedExample(candidates)
|
|
1812
|
+
: undefined;
|
|
1525
1813
|
if (authoredExample) {
|
|
1526
1814
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, authoredExample, candidates), 'heuristic', request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
1527
1815
|
}
|
|
1528
|
-
const exactCompatible = candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible");
|
|
1816
|
+
const exactCompatible = !shouldUseMeaningCall ? candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible") : [];
|
|
1529
1817
|
if (exactCompatible.length === 1 && !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
|
|
1530
1818
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
1531
1819
|
}
|
|
1532
|
-
const dominant =
|
|
1820
|
+
const dominant = !shouldUseMeaningCall
|
|
1821
|
+
? dominantCompatibleGovernedCandidate(candidates)
|
|
1822
|
+
: undefined;
|
|
1533
1823
|
if (dominant) {
|
|
1534
1824
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, dominant, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
1535
1825
|
}
|
|
1536
|
-
if (shouldDeferCompositionalFollowUpToExecutor(base, candidates)) {
|
|
1826
|
+
if (!shouldUseMeaningCall && shouldDeferCompositionalFollowUpToExecutor(base, candidates)) {
|
|
1537
1827
|
return routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative');
|
|
1538
1828
|
}
|
|
1539
1829
|
const key = cacheKey(request, evidence);
|
|
@@ -1577,7 +1867,55 @@ export function createHybridRouter(options = {}) {
|
|
|
1577
1867
|
if (resolution) {
|
|
1578
1868
|
const validated = validateMeaningResolution(resolution, candidates);
|
|
1579
1869
|
if (validated.ok) {
|
|
1580
|
-
|
|
1870
|
+
const safeResolution = preventDegenerateRankingResolution(validated.resolution, evidence, candidates, request.question);
|
|
1871
|
+
// Meaning interpretation is still required for a fresh turn,
|
|
1872
|
+
// but it cannot invent a ranking measure when the user only
|
|
1873
|
+
// supplied an entity. Preserve the precise follow-up after the
|
|
1874
|
+
// bounded call so this does not regress into a generic block
|
|
1875
|
+
// or a repeated customer-count answer.
|
|
1876
|
+
//
|
|
1877
|
+
// Gate on the MEANING MODEL's classification, not on
|
|
1878
|
+
// `questionTypeFromText`. The text heuristic only looks for
|
|
1879
|
+
// words like "top", so it also claimed "what region does the
|
|
1880
|
+
// top customer belong to" (an attribute lookup) and "top
|
|
1881
|
+
// products in Philadelphia and the customers who bought them"
|
|
1882
|
+
// (a compound turn) — both were preempted here and never
|
|
1883
|
+
// routed, even though the bounded call had just resolved them.
|
|
1884
|
+
//
|
|
1885
|
+
// A resolution that named an execution target is honored:
|
|
1886
|
+
// `preventDegenerateRankingResolution` above has already
|
|
1887
|
+
// downgraded a same-grain entity count to `clarify`, so
|
|
1888
|
+
// anything still standing is a governed measure the model
|
|
1889
|
+
// selected from qualified candidate ids.
|
|
1890
|
+
const resolutionResolvedRanking = safeResolution.recommendedRoute !== 'clarify'
|
|
1891
|
+
&& Boolean(safeResolution.recommendedExecutionId
|
|
1892
|
+
|| safeResolution.selectedConceptIds.length > 0);
|
|
1893
|
+
// An explicit SELECTION answers this gate as well as words in
|
|
1894
|
+
// the question do. `hasExplicitRankingMeasure` reads the
|
|
1895
|
+
// question TEXT, and clicking a choice never changes the text —
|
|
1896
|
+
// so picking `customers.average_order_value` re-asked "Top by
|
|
1897
|
+
// which governed metric?" with the same three options, forever.
|
|
1898
|
+
// A degenerate pick is still refused above, so anything
|
|
1899
|
+
// arriving here is a measure the reader chose from governed
|
|
1900
|
+
// evidence.
|
|
1901
|
+
const explicitRankingSelection = Boolean(request.selectedEvidenceId)
|
|
1902
|
+
&& Boolean(selectedEvidence)
|
|
1903
|
+
&& !isDegenerateRankingMetric(request.question, evidence, selectedEvidence);
|
|
1904
|
+
if (safeResolution.questionType === 'ranking'
|
|
1905
|
+
&& !hasExplicitRankingMeasure(request.question, evidence)
|
|
1906
|
+
&& !resolutionResolvedRanking
|
|
1907
|
+
&& !explicitRankingSelection) {
|
|
1908
|
+
return bareRankingClarification(base, retrievalTrace(evidence, candidates), request.question, evidence,
|
|
1909
|
+
// Supplemental clarification cards carry the ranking
|
|
1910
|
+
// measures for the requested entity, which the execution
|
|
1911
|
+
// candidate set deliberately does not.
|
|
1912
|
+
clarificationCandidates);
|
|
1913
|
+
}
|
|
1914
|
+
const deterministicGap = deterministicPrePlanClarification(request, base, evidence, clarificationCandidates);
|
|
1915
|
+
if (deterministicGap && safeResolution.recommendedRoute === 'clarify') {
|
|
1916
|
+
return deterministicGap;
|
|
1917
|
+
}
|
|
1918
|
+
return remember(key, routeDecisionForResolution(base, evidence, candidates, safeResolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative'));
|
|
1581
1919
|
}
|
|
1582
1920
|
const invalidResolution = {
|
|
1583
1921
|
interpretedQuestion: request.question,
|
|
@@ -1610,7 +1948,15 @@ export function createHybridRouter(options = {}) {
|
|
|
1610
1948
|
// retrieval signal or permitting a general-knowledge misroute.
|
|
1611
1949
|
meaningResolverReachable = false;
|
|
1612
1950
|
}
|
|
1613
|
-
|
|
1951
|
+
const fallbackDecision = routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative', meaningResolverReachable);
|
|
1952
|
+
if (!shouldUseMeaningCall)
|
|
1953
|
+
return fallbackDecision;
|
|
1954
|
+
// The provider was unavailable or returned malformed JSON. Apply the
|
|
1955
|
+
// deterministic clarification only after the bounded meaning attempt
|
|
1956
|
+
// has been exhausted; this preserves a precise recovery path without
|
|
1957
|
+
// allowing the generic governed error to terminate the question.
|
|
1958
|
+
return deterministicPrePlanClarification(request, base, evidence, clarificationCandidates)
|
|
1959
|
+
?? fallbackDecision;
|
|
1614
1960
|
}
|
|
1615
1961
|
}
|
|
1616
1962
|
// Legacy/no-evidence path. A confident analytical heuristic stays offline;
|