@duckcodeailabs/dql-agent 1.13.4 → 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 +87 -7
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +559 -40
- 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/agentic/tool-loop.d.ts.map +1 -1
- package/dist/agentic/tool-loop.js +100 -32
- package/dist/agentic/tool-loop.js.map +1 -1
- package/dist/aggregation-safety-proof.d.ts +41 -0
- package/dist/aggregation-safety-proof.d.ts.map +1 -0
- package/dist/aggregation-safety-proof.js +511 -0
- package/dist/aggregation-safety-proof.js.map +1 -0
- package/dist/analytical-compatibility.d.ts.map +1 -1
- package/dist/analytical-compatibility.js +42 -25
- package/dist/analytical-compatibility.js.map +1 -1
- package/dist/analytical-execution-graph.d.ts +4 -21
- package/dist/analytical-execution-graph.d.ts.map +1 -1
- package/dist/analytical-execution-graph.js +27 -0
- package/dist/analytical-execution-graph.js.map +1 -1
- package/dist/analytical-frame.d.ts +12 -1
- package/dist/analytical-frame.d.ts.map +1 -1
- package/dist/analytical-frame.js +102 -2
- package/dist/analytical-frame.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 +54 -1
- package/dist/analytical-result-facts.d.ts.map +1 -1
- package/dist/analytical-result-facts.js +183 -0
- package/dist/analytical-result-facts.js.map +1 -1
- package/dist/answer-loop.d.ts +44 -3
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +771 -38
- package/dist/answer-loop.js.map +1 -1
- package/dist/cascade/budgets.js +1 -1
- package/dist/cascade/route-policy.d.ts +1 -1
- package/dist/cascade/route-policy.d.ts.map +1 -1
- package/dist/cascade/route-policy.js +2 -0
- package/dist/cascade/route-policy.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/generated-analytical-proposal.d.ts +37 -0
- package/dist/generated-analytical-proposal.d.ts.map +1 -0
- package/dist/generated-analytical-proposal.js +219 -0
- package/dist/generated-analytical-proposal.js.map +1 -0
- package/dist/index.d.ts +11 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -3
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +8 -1
- package/dist/intent-controller.d.ts.map +1 -1
- package/dist/intent-controller.js.map +1 -1
- package/dist/kg/build.js +102 -6
- package/dist/kg/build.js.map +1 -1
- package/dist/meaning-resolution.d.ts +30 -0
- package/dist/meaning-resolution.d.ts.map +1 -1
- package/dist/meaning-resolution.js +61 -8
- package/dist/meaning-resolution.js.map +1 -1
- package/dist/metadata/analysis-planner.d.ts.map +1 -1
- package/dist/metadata/analysis-planner.js +46 -2
- package/dist/metadata/analysis-planner.js.map +1 -1
- package/dist/metadata/block-fit.d.ts.map +1 -1
- package/dist/metadata/block-fit.js +55 -2
- package/dist/metadata/block-fit.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 +408 -36
- package/dist/metadata/meaning-evidence.js.map +1 -1
- package/dist/metadata/sql-context-validation.d.ts +3 -0
- package/dist/metadata/sql-context-validation.d.ts.map +1 -1
- package/dist/metadata/sql-context-validation.js +16 -5
- package/dist/metadata/sql-context-validation.js.map +1 -1
- package/dist/plan-execution-adapter.d.ts +9 -1
- package/dist/plan-execution-adapter.d.ts.map +1 -1
- package/dist/plan-execution-adapter.js +81 -32
- package/dist/plan-execution-adapter.js.map +1 -1
- package/dist/provider-egress.d.ts +122 -0
- package/dist/provider-egress.d.ts.map +1 -0
- package/dist/provider-egress.js +504 -0
- package/dist/provider-egress.js.map +1 -0
- package/dist/providers/claude.d.ts.map +1 -1
- package/dist/providers/claude.js +76 -33
- package/dist/providers/claude.js.map +1 -1
- package/dist/providers/dispatch.d.ts +15 -0
- package/dist/providers/dispatch.d.ts.map +1 -0
- package/dist/providers/dispatch.js +25 -0
- package/dist/providers/dispatch.js.map +1 -0
- package/dist/providers/gemini.d.ts.map +1 -1
- package/dist/providers/gemini.js +16 -8
- package/dist/providers/gemini.js.map +1 -1
- package/dist/providers/index.d.ts +2 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +1 -0
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/ollama.d.ts.map +1 -1
- package/dist/providers/ollama.js +29 -10
- package/dist/providers/ollama.js.map +1 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +92 -36
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/types.d.ts +43 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/relationship-proof.d.ts +19 -0
- package/dist/relationship-proof.d.ts.map +1 -0
- package/dist/relationship-proof.js +75 -0
- package/dist/relationship-proof.js.map +1 -0
- package/dist/research-loop.d.ts.map +1 -1
- package/dist/research-loop.js +17 -11
- package/dist/research-loop.js.map +1 -1
- package/dist/resolved-analytical-plan.d.ts +25 -3
- package/dist/resolved-analytical-plan.d.ts.map +1 -1
- package/dist/resolved-analytical-plan.js +460 -31
- package/dist/resolved-analytical-plan.js.map +1 -1
- package/dist/router.d.ts +38 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +1060 -53
- package/dist/router.js.map +1 -1
- package/dist/semantic-identities.d.ts +18 -0
- package/dist/semantic-identities.d.ts.map +1 -0
- package/dist/semantic-identities.js +60 -0
- package/dist/semantic-identities.js.map +1 -0
- package/dist/semantic-proof-authority.d.ts +29 -0
- package/dist/semantic-proof-authority.d.ts.map +1 -0
- package/dist/semantic-proof-authority.js +34 -0
- package/dist/semantic-proof-authority.js.map +1 -0
- package/package.json +4 -4
package/dist/router.js
CHANGED
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
* question never pays twice.
|
|
16
16
|
*/
|
|
17
17
|
import { classifyConversationalTurn, decideAgentAction, } from "./intent-controller.js";
|
|
18
|
-
import { buildMeaningEvidencePackage, defaultQueryIntent, findExplicitEvidenceReference, questionTypeFromText, routeForEvidenceCandidate, validateMeaningResolution, } from "./meaning-resolution.js";
|
|
18
|
+
import { buildMeaningEvidencePackage, canonicalizeMetricMeasureCandidates, defaultQueryIntent, findExplicitEvidenceReference, questionTypeFromText, routeForEvidenceCandidate, validateMeaningResolution, } from "./meaning-resolution.js";
|
|
19
19
|
import { normalizeAnalyticalQuestionFrameV2 } from "@duckcodeailabs/dql-core";
|
|
20
20
|
import { buildResolvedAnalyticalPlan, } from "./resolved-analytical-plan.js";
|
|
21
21
|
import { normalizeEvidenceAnalyticalCapability, solveAnalyticalCompatibility, } from "./analytical-compatibility.js";
|
|
22
|
-
import { buildDeterministicAnalyticalFrame } from "./analytical-frame.js";
|
|
22
|
+
import { buildDeterministicAnalyticalFrame, projectResolvedAnalyticalFrame } from "./analytical-frame.js";
|
|
23
23
|
import { conversationHistoryFromContext, renderConversationEnvelopeForPrompt, } from "./conversation/snapshot.js";
|
|
24
24
|
const DEFAULT_THRESHOLD = 0.7;
|
|
25
25
|
const DEFAULT_CACHE_SIZE = 200;
|
|
@@ -414,14 +414,22 @@ function retrievalTrace(evidence, candidates) {
|
|
|
414
414
|
};
|
|
415
415
|
}
|
|
416
416
|
function routeDecisionForResolution(base, evidence, candidates, resolution, source, question = resolution.interpretedQuestion, mode = 'authoritative') {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
417
|
+
let planBoundResolution = resolution;
|
|
418
|
+
if (resolution.analyticalFrame && resolution.recommendedRoute === 'semantic') {
|
|
419
|
+
const { analyticalFrame: sourceFrame, ...resolutionWithoutFrame } = resolution;
|
|
420
|
+
const bindingPlan = buildResolvedAnalyticalPlan({
|
|
421
|
+
question,
|
|
422
|
+
resolution: resolutionWithoutFrame,
|
|
423
|
+
evidence,
|
|
424
|
+
candidates,
|
|
425
|
+
mode,
|
|
426
|
+
});
|
|
427
|
+
planBoundResolution = {
|
|
428
|
+
...resolution,
|
|
429
|
+
analyticalFrame: projectResolvedAnalyticalFrame({ plan: bindingPlan, sourceFrame }),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
const routedResolution = enforceAnalyticalCompatibility(planBoundResolution, evidence, candidates);
|
|
425
433
|
const resolvedAnalyticalPlan = buildResolvedAnalyticalPlan({
|
|
426
434
|
question,
|
|
427
435
|
resolution: routedResolution,
|
|
@@ -429,13 +437,22 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
|
|
|
429
437
|
candidates,
|
|
430
438
|
mode,
|
|
431
439
|
});
|
|
440
|
+
const reconciliation = reconcileResolvedPlanOutcome(routedResolution, resolvedAnalyticalPlan, candidates);
|
|
441
|
+
const needsClarification = reconciliation.outcome === 'clarify';
|
|
442
|
+
const terminallyBlocked = reconciliation.outcome === 'modeling_gap'
|
|
443
|
+
|| reconciliation.outcome === 'policy_blocked';
|
|
444
|
+
const analytical = routedResolution.questionType === "diagnosis" ||
|
|
445
|
+
routedResolution.questionType === "research";
|
|
446
|
+
const reason = reconciliation.reason;
|
|
432
447
|
return {
|
|
433
448
|
...base,
|
|
434
449
|
action: needsClarification
|
|
435
450
|
? "clarify"
|
|
436
|
-
:
|
|
437
|
-
? "
|
|
438
|
-
:
|
|
451
|
+
: terminallyBlocked
|
|
452
|
+
? "block"
|
|
453
|
+
: analytical
|
|
454
|
+
? "investigate"
|
|
455
|
+
: "answer",
|
|
439
456
|
confidence: routedResolution.confidence === "high"
|
|
440
457
|
? 0.9
|
|
441
458
|
: routedResolution.confidence === "medium"
|
|
@@ -449,17 +466,117 @@ function routeDecisionForResolution(base, evidence, candidates, resolution, sour
|
|
|
449
466
|
resolvedAnalyticalPlan,
|
|
450
467
|
retrievalEvidence: retrievalTrace(evidence, candidates),
|
|
451
468
|
requiresClarification: needsClarification,
|
|
469
|
+
...(terminallyBlocked
|
|
470
|
+
? {
|
|
471
|
+
terminalOutcome: {
|
|
472
|
+
kind: reconciliation.outcome === 'policy_blocked'
|
|
473
|
+
? 'policy_blocked'
|
|
474
|
+
: 'modeling_gap',
|
|
475
|
+
code: reconciliation.outcome === 'policy_blocked'
|
|
476
|
+
? 'ANALYTICAL_POLICY_BLOCKED'
|
|
477
|
+
: 'ANALYTICAL_MODELING_GAP',
|
|
478
|
+
message: reconciliation.reason,
|
|
479
|
+
candidateIds: resolvedAnalyticalPlan.resolutionFailure?.candidateIds ?? [],
|
|
480
|
+
},
|
|
481
|
+
}
|
|
482
|
+
: {}),
|
|
452
483
|
...(needsClarification
|
|
453
|
-
? { clarificationOptions:
|
|
484
|
+
? { clarificationOptions: reconciliation.options }
|
|
454
485
|
: {}),
|
|
455
486
|
...(needsClarification
|
|
456
487
|
? {
|
|
457
|
-
clarifyingQuestion:
|
|
458
|
-
buildEvidenceClarification(candidates, routedResolution.missingInformation),
|
|
488
|
+
clarifyingQuestion: reconciliation.question,
|
|
459
489
|
}
|
|
460
490
|
: {}),
|
|
461
491
|
};
|
|
462
492
|
}
|
|
493
|
+
/**
|
|
494
|
+
* The immutable RAP is the final routing authority. Meaning may nominate an
|
|
495
|
+
* execution route, but cannot leave the router claiming an answer after the
|
|
496
|
+
* host has retained an ambiguous or blocked qualified binding.
|
|
497
|
+
*/
|
|
498
|
+
function reconcileResolvedPlanOutcome(resolution, plan, candidates) {
|
|
499
|
+
if (plan.capability !== 'blocked') {
|
|
500
|
+
return {
|
|
501
|
+
outcome: 'ready',
|
|
502
|
+
reason: `Resolved the question against ${plan.selectedConceptIds.join(', ')}: ${resolution.interpretedQuestion}`,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
const bindings = [
|
|
506
|
+
...plan.query.measures.map((binding) => ({ kind: 'measure', binding })),
|
|
507
|
+
...plan.query.dimensions.map((binding) => ({ kind: 'dimension', binding })),
|
|
508
|
+
...plan.query.filters.map((filter) => ({ kind: 'filter', binding: filter.binding })),
|
|
509
|
+
].filter(({ binding }) => binding.status !== 'resolved');
|
|
510
|
+
const qualifiedChoiceIds = [...new Set(bindings.flatMap(({ binding }) => binding.candidateIds))].sort();
|
|
511
|
+
const userResolvableBinding = bindings.some(({ binding }) => binding.candidateIds.length > 0);
|
|
512
|
+
if (plan.resolutionFailure?.outcome === 'policy_blocked') {
|
|
513
|
+
return {
|
|
514
|
+
outcome: 'policy_blocked',
|
|
515
|
+
reason: `Policy blocked the selected analytical plan: ${plan.missingInformation.join(' ') || 'review the retained policy diagnostic.'}`,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
if (plan.resolutionFailure?.outcome === 'modeling_gap') {
|
|
519
|
+
return {
|
|
520
|
+
outcome: 'modeling_gap',
|
|
521
|
+
reason: `The selected analytical plan has a governed modeling gap: ${plan.missingInformation.join(' ') || 'review the retained capability diagnostic.'}`,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
if (userResolvableBinding || plan.resolutionFailure?.outcome === 'clarify') {
|
|
525
|
+
const optionIds = qualifiedChoiceIds.length > 0
|
|
526
|
+
? qualifiedChoiceIds
|
|
527
|
+
: [...new Set((resolution.compatibilityFailures ?? []).flatMap((failure) => failure.candidateIds))].sort();
|
|
528
|
+
const options = optionIds.length > 0
|
|
529
|
+
? clarificationOptionsForQualifiedIds(optionIds, candidates)
|
|
530
|
+
: buildClarificationOptions(candidates);
|
|
531
|
+
const bindingSummary = bindings.map(({ kind, binding }) => `${kind} “${binding.requested}” is ${binding.status}`).join('; ');
|
|
532
|
+
const question = routedClarificationQuestion(resolution, bindings, options);
|
|
533
|
+
return {
|
|
534
|
+
outcome: 'clarify',
|
|
535
|
+
reason: `The immutable analytical plan needs one identifier-bound choice: ${bindingSummary || plan.missingInformation.join(' ')}`,
|
|
536
|
+
question,
|
|
537
|
+
options,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
if (resolution.confidence === 'low' || resolution.recommendedRoute === 'clarify') {
|
|
541
|
+
return {
|
|
542
|
+
outcome: 'clarify',
|
|
543
|
+
reason: `The retrieved evidence needs one governed meaning choice: ${plan.missingInformation.join(' ') || resolution.interpretedQuestion}`,
|
|
544
|
+
question: resolution.clarifyingQuestion ?? buildEvidenceClarification(candidates, plan.missingInformation),
|
|
545
|
+
options: buildClarificationOptions(candidates),
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
return {
|
|
549
|
+
outcome: 'modeling_gap',
|
|
550
|
+
reason: `The selected analytical plan is not executable from the governed model: ${plan.missingInformation.join(' ') || 'review its capability and relationship proof.'}`,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
function clarificationOptionsForQualifiedIds(ids, candidates) {
|
|
554
|
+
return ids.slice(0, 3).map((id) => {
|
|
555
|
+
const candidate = candidates.find((item) => item.id === id || item.qualifiedId === id);
|
|
556
|
+
return {
|
|
557
|
+
id,
|
|
558
|
+
label: candidate?.name ?? qualifiedIdLabel(id),
|
|
559
|
+
...(candidate?.definition?.trim() ? { description: candidate.definition.trim() } : {}),
|
|
560
|
+
kind: candidate?.kind ?? 'semantic_member',
|
|
561
|
+
};
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
function qualifiedIdLabel(id) {
|
|
565
|
+
const local = id.split(/[:./]/).filter(Boolean).at(-1) ?? id;
|
|
566
|
+
return local.replace(/[_-]+/g, ' ').replace(/\b\w/g, (character) => character.toUpperCase());
|
|
567
|
+
}
|
|
568
|
+
function routedClarificationQuestion(resolution, bindings, options) {
|
|
569
|
+
if (resolution.clarifyingQuestion && !/^The analytical frame has unresolved ambiguity:/i.test(resolution.clarifyingQuestion)) {
|
|
570
|
+
return resolution.clarifyingQuestion;
|
|
571
|
+
}
|
|
572
|
+
const first = bindings[0];
|
|
573
|
+
const labels = options.map((option) => option.label);
|
|
574
|
+
if (first && labels.length > 1) {
|
|
575
|
+
return `Which governed ${first.kind} should I use for “${first.binding.requested}”: ${labels.join(' or ')}?`;
|
|
576
|
+
}
|
|
577
|
+
return resolution.clarifyingQuestion
|
|
578
|
+
?? `Which governed binding should I use before running this query?`;
|
|
579
|
+
}
|
|
463
580
|
function continueCascadeAfterIncompleteSelection(base, evidence, candidates, selected) {
|
|
464
581
|
return {
|
|
465
582
|
...base,
|
|
@@ -478,7 +595,14 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
|
|
|
478
595
|
const requestedMetricCount = new Set((evidence.parsedIntent?.measures ?? resolution.queryIntent.measures)
|
|
479
596
|
.map(normalizeMetricPhrase)
|
|
480
597
|
.filter(Boolean)).size;
|
|
598
|
+
// Parser hints may split one business metric name into overlapping measure
|
|
599
|
+
// tokens (for example "rollover balance amount" -> "balance", "amount").
|
|
600
|
+
// Treat several hints as a multi-metric contract only when the question
|
|
601
|
+
// actually coordinates separate measures; otherwise one exact qualified
|
|
602
|
+
// capability is allowed to bind all synonymous hints.
|
|
603
|
+
const explicitlyCoordinatesMetrics = /(?:,|\b(?:and|plus|versus|vs\.?|along with)\b)/i.test(resolution.interpretedQuestion);
|
|
481
604
|
if (requestedMetricCount > 1
|
|
605
|
+
&& explicitlyCoordinatesMetrics
|
|
482
606
|
&& (missingMetricTerms.length > 0
|
|
483
607
|
|| (resolution.analyticalFrame?.metricConceptIds.length ?? 0) < requestedMetricCount)) {
|
|
484
608
|
const missing = missingMetricTerms.length > 0
|
|
@@ -529,6 +653,8 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
|
|
|
529
653
|
recommendedExecutionId: result.candidateId,
|
|
530
654
|
recommendedRoute: result.route,
|
|
531
655
|
missingInformation: [],
|
|
656
|
+
compatibilityOutcome: undefined,
|
|
657
|
+
compatibilityFailures: undefined,
|
|
532
658
|
};
|
|
533
659
|
}
|
|
534
660
|
// A question asking for SEVERAL metrics ("revenue and refunds by month") is a
|
|
@@ -547,12 +673,26 @@ function enforceAnalyticalCompatibility(resolution, evidence, candidates) {
|
|
|
547
673
|
};
|
|
548
674
|
}
|
|
549
675
|
const failures = result.failures.map((failure) => failure.message);
|
|
676
|
+
const compatibilityFailures = result.failures.map((failure) => ({
|
|
677
|
+
code: failure.code,
|
|
678
|
+
field: failure.field,
|
|
679
|
+
message: failure.message,
|
|
680
|
+
candidateIds: [...(failure.candidateIds ?? [])],
|
|
681
|
+
}));
|
|
682
|
+
const policyFailure = result.failures.some((failure) => failure.code.startsWith('POLICY_'));
|
|
683
|
+
const compatibilityOutcome = policyFailure
|
|
684
|
+
? 'policy_blocked'
|
|
685
|
+
: result.status === 'clarify'
|
|
686
|
+
? 'clarify'
|
|
687
|
+
: 'modeling_gap';
|
|
550
688
|
return {
|
|
551
689
|
...resolution,
|
|
552
690
|
analyticalFrame: result.frame,
|
|
553
691
|
analyticalPolicyIds: result.policyIds,
|
|
554
692
|
confidence: result.status === "clarify" ? "low" : resolution.confidence,
|
|
555
693
|
recommendedRoute: "clarify",
|
|
694
|
+
compatibilityOutcome,
|
|
695
|
+
compatibilityFailures,
|
|
556
696
|
missingInformation: [
|
|
557
697
|
...new Set([...resolution.missingInformation, ...failures]),
|
|
558
698
|
],
|
|
@@ -570,12 +710,72 @@ function buildClarificationOptions(candidates) {
|
|
|
570
710
|
const pool = governed.length > 1
|
|
571
711
|
? governed
|
|
572
712
|
: candidates.filter((candidate) => candidate.compatibility !== "incompatible");
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
713
|
+
const chosen = pool.slice(0, 3);
|
|
714
|
+
// Two candidates can legitimately share a display name (a dbt model and its
|
|
715
|
+
// MetricFlow measure are both "customers"). Rendering both as "customers"
|
|
716
|
+
// asks the user to choose between two identical-looking buttons, so the
|
|
717
|
+
// duplicates carry their distinguishing identity.
|
|
718
|
+
const nameCounts = new Map();
|
|
719
|
+
for (const candidate of chosen) {
|
|
720
|
+
nameCounts.set(candidate.name, (nameCounts.get(candidate.name) ?? 0) + 1);
|
|
721
|
+
}
|
|
722
|
+
return chosen.map((candidate) => {
|
|
723
|
+
const ambiguousName = (nameCounts.get(candidate.name) ?? 0) > 1;
|
|
724
|
+
const description = humanizeCandidateDefinition(candidate.definition);
|
|
725
|
+
return {
|
|
726
|
+
id: candidate.id,
|
|
727
|
+
label: ambiguousName
|
|
728
|
+
? `${candidate.name} (${candidateKindLabel(candidate.kind)})`
|
|
729
|
+
: candidate.name,
|
|
730
|
+
...(description ? { description } : {}),
|
|
731
|
+
kind: candidate.kind,
|
|
732
|
+
};
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
function candidateKindLabel(kind) {
|
|
736
|
+
if (kind === 'certified_block')
|
|
737
|
+
return 'certified block';
|
|
738
|
+
if (kind === 'semantic_metric')
|
|
739
|
+
return 'metric';
|
|
740
|
+
if (kind === 'semantic_member')
|
|
741
|
+
return 'model field';
|
|
742
|
+
return kind.replace(/[_-]+/g, ' ');
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
745
|
+
* A candidate's `definition` is sometimes the raw semantic-layer record —
|
|
746
|
+
* `label: customers\naggregation: count_distinct\ntable: "..."\nexpr: customer_id`.
|
|
747
|
+
* Dumping that into a question asks a business user to disambiguate by reading
|
|
748
|
+
* YAML. Turn a recognisable key/value record into one plain sentence; leave
|
|
749
|
+
* genuine authored prose alone.
|
|
750
|
+
*/
|
|
751
|
+
export function humanizeCandidateDefinition(definition) {
|
|
752
|
+
const text = definition?.trim();
|
|
753
|
+
if (!text)
|
|
754
|
+
return undefined;
|
|
755
|
+
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
756
|
+
const fields = new Map();
|
|
757
|
+
for (const line of lines) {
|
|
758
|
+
const match = /^([a-z_][a-z0-9_]*)\s*:\s*(.+)$/i.exec(line);
|
|
759
|
+
if (match)
|
|
760
|
+
fields.set(match[1].toLowerCase(), match[2].trim().replace(/^["']|["']$/g, ''));
|
|
761
|
+
}
|
|
762
|
+
// Only treat it as a record when MOST of it is key/value pairs; a one-line
|
|
763
|
+
// sentence containing a colon is prose, not a dump.
|
|
764
|
+
if (fields.size < 2 || fields.size < lines.length - 1) {
|
|
765
|
+
return text.replace(/\s+/g, ' ').slice(0, 200);
|
|
766
|
+
}
|
|
767
|
+
const aggregation = fields.get('aggregation');
|
|
768
|
+
const expr = fields.get('expr');
|
|
769
|
+
const table = fields.get('table');
|
|
770
|
+
const parts = [];
|
|
771
|
+
if (aggregation)
|
|
772
|
+
parts.push(`${aggregation.replace(/_/g, ' ')}${expr ? ` of ${expr}` : ''}`);
|
|
773
|
+
else if (expr)
|
|
774
|
+
parts.push(expr);
|
|
775
|
+
if (table)
|
|
776
|
+
parts.push(`from ${table.split('.').pop()?.replace(/"/g, '') ?? table}`);
|
|
777
|
+
const summary = parts.join(' ');
|
|
778
|
+
return summary ? `${summary}.` : undefined;
|
|
579
779
|
}
|
|
580
780
|
function buildEvidenceClarification(candidates, missing = []) {
|
|
581
781
|
const governedChoices = candidates.filter((candidate) => candidate.compatibility !== "incompatible"
|
|
@@ -593,21 +793,151 @@ function buildEvidenceClarification(candidates, missing = []) {
|
|
|
593
793
|
return `I found relevant governed context, but need ${missing.join(" and ")}. What should I use?`;
|
|
594
794
|
return "Which governed business meaning should I use for this question?";
|
|
595
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
|
+
}
|
|
596
903
|
function directResolution(request, evidence, candidate, candidates) {
|
|
904
|
+
const inferredQuestionType = questionTypeFromText(request.question);
|
|
905
|
+
const questionType = inferredQuestionType === 'definition'
|
|
906
|
+
&& candidate.kind === 'semantic_metric'
|
|
907
|
+
&& candidate.exactMatch
|
|
908
|
+
&& Boolean(normalizeEvidenceAnalyticalCapability(candidate).capability)
|
|
909
|
+
&& /^\s*what (?:is|was|were|are)\b/i.test(request.question)
|
|
910
|
+
&& !/\b(?:define|definition|meaning|mean)\b/i.test(request.question)
|
|
911
|
+
? 'value'
|
|
912
|
+
: inferredQuestionType;
|
|
597
913
|
const metricCandidates = explicitlyRequestedMetricCandidates(request.question, evidence, candidate, candidates);
|
|
598
914
|
const analyticalFrame = buildDeterministicAnalyticalFrame({
|
|
599
915
|
question: request.question,
|
|
916
|
+
questionType,
|
|
600
917
|
evidence,
|
|
601
918
|
metricCandidate: candidate,
|
|
602
919
|
metricCandidates,
|
|
603
920
|
candidates,
|
|
604
921
|
});
|
|
922
|
+
const queryIntent = defaultQueryIntent(evidence);
|
|
923
|
+
const memberCandidates = candidates.filter((item) => {
|
|
924
|
+
if (item.kind !== 'semantic_member' || item.compatibility === 'incompatible')
|
|
925
|
+
return false;
|
|
926
|
+
const identities = [item.name, ...(item.aliases ?? [])].map(normalizeMetricPhrase).filter(Boolean);
|
|
927
|
+
return queryIntent.filters.some((filter) => identities.includes(normalizeMetricPhrase(filter.value)));
|
|
928
|
+
});
|
|
929
|
+
const canonicalFilters = queryIntent.filters.map((filter) => {
|
|
930
|
+
const member = memberCandidates.find((item) => [item.name, ...(item.aliases ?? [])]
|
|
931
|
+
.map(normalizeMetricPhrase)
|
|
932
|
+
.includes(normalizeMetricPhrase(filter.value)));
|
|
933
|
+
return member ? { ...filter, value: member.name } : filter;
|
|
934
|
+
});
|
|
605
935
|
return {
|
|
606
936
|
interpretedQuestion: request.question,
|
|
607
|
-
questionType
|
|
608
|
-
selectedConceptIds: metricCandidates.map((
|
|
937
|
+
questionType,
|
|
938
|
+
selectedConceptIds: [...metricCandidates, ...memberCandidates].map((item) => item.id),
|
|
609
939
|
recommendedExecutionId: candidate.id,
|
|
610
|
-
queryIntent:
|
|
940
|
+
queryIntent: { ...queryIntent, filters: canonicalFilters },
|
|
611
941
|
rejectedCandidates: [],
|
|
612
942
|
confidence: "high",
|
|
613
943
|
missingInformation: [],
|
|
@@ -623,6 +953,12 @@ function explicitlyRequestedMetricCandidates(question, evidence, primary, candid
|
|
|
623
953
|
// representations of the same request, not additional requested metrics.
|
|
624
954
|
if (requestedTerms.length <= 1)
|
|
625
955
|
return [primary];
|
|
956
|
+
const primaryNames = [primary.name, ...(primary.aliases ?? [])]
|
|
957
|
+
.map(normalizeMetricPhrase)
|
|
958
|
+
.filter(Boolean);
|
|
959
|
+
if (requestedTerms.every((term) => primaryNames.some((name) => metricTermsMatch(name, term)))) {
|
|
960
|
+
return [primary];
|
|
961
|
+
}
|
|
626
962
|
const questionText = normalizeMetricPhrase(question);
|
|
627
963
|
const metrics = candidates.filter((candidate) => {
|
|
628
964
|
if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
|
|
@@ -646,6 +982,7 @@ function explicitlyRequestedMetricCandidates(question, evidence, primary, candid
|
|
|
646
982
|
function normalizeMetricPhrase(value) {
|
|
647
983
|
return value
|
|
648
984
|
.toLowerCase()
|
|
985
|
+
.replace(/%/g, ' percentage ')
|
|
649
986
|
.replace(/[_./:-]+/g, ' ')
|
|
650
987
|
.replace(/[^a-z0-9 ]+/g, ' ')
|
|
651
988
|
.replace(/\s+/g, ' ')
|
|
@@ -686,36 +1023,528 @@ function exactMultiMetricPrimary(question, evidence, candidates) {
|
|
|
686
1023
|
? primary
|
|
687
1024
|
: undefined;
|
|
688
1025
|
}
|
|
689
|
-
function routeWithoutMeaningModel(request, base, evidence, candidates, planMode = 'authoritative'
|
|
1026
|
+
function routeWithoutMeaningModel(request, base, evidence, candidates, planMode = 'authoritative',
|
|
1027
|
+
/**
|
|
1028
|
+
* Whether DQL may commit to the best-ranked reading on the user's behalf.
|
|
1029
|
+
*
|
|
1030
|
+
* False when the meaning resolver never got to run (provider outage). Two
|
|
1031
|
+
* genuinely different metrics that merely share an alias — `booked_revenue`
|
|
1032
|
+
* and `billed_revenue` both aliased "revenue" — must not be settled by
|
|
1033
|
+
* lexical rank alone with the semantic judgment switched off. AGT-017.
|
|
1034
|
+
*/
|
|
1035
|
+
mayAssumeInterpretation = true) {
|
|
690
1036
|
const multiMetricPrimary = exactMultiMetricPrimary(request.question, evidence, candidates);
|
|
691
1037
|
if (multiMetricPrimary) {
|
|
692
1038
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, planMode);
|
|
693
1039
|
}
|
|
694
|
-
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));
|
|
695
1050
|
if (exactCompatible.length === 1 &&
|
|
696
1051
|
!hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
|
|
697
1052
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, planMode);
|
|
698
1053
|
}
|
|
699
|
-
const semanticMetric = uniqueExecutableSemanticMetric(evidence,
|
|
1054
|
+
const semanticMetric = uniqueExecutableSemanticMetric(evidence, rankingCandidates);
|
|
700
1055
|
if (semanticMetric) {
|
|
701
1056
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, semanticMetric, candidates), "heuristic", request.question, planMode);
|
|
702
1057
|
}
|
|
703
|
-
|
|
1058
|
+
// Last resort before asking the user: commit to the best governed reading.
|
|
1059
|
+
// Clarifying whenever retrieval returned more than one candidate meant a
|
|
1060
|
+
// `type: simple` metric, the measure it wraps, and the model that holds them
|
|
1061
|
+
// were offered as three competing "meanings" of the same number.
|
|
1062
|
+
const best = mayAssumeInterpretation
|
|
1063
|
+
? bestGovernedInterpretation(request.question, rankingCandidates)
|
|
1064
|
+
: undefined;
|
|
1065
|
+
if (best) {
|
|
1066
|
+
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, best, candidates), "heuristic", request.question, planMode);
|
|
1067
|
+
}
|
|
1068
|
+
return unresolvedAnalyticalPlanDecision(base, evidence, candidates, request.question);
|
|
1069
|
+
}
|
|
1070
|
+
/** Leaf identity of a governed candidate, ignoring its source qualification. */
|
|
1071
|
+
function candidateLeafName(candidate) {
|
|
1072
|
+
const identity = candidate.qualifiedId ?? candidate.id;
|
|
1073
|
+
return (identity.split(/[.:]/).at(-1) ?? candidate.name).trim().toLowerCase();
|
|
1074
|
+
}
|
|
1075
|
+
/** Prefer the most authoritative representative of one underlying meaning. */
|
|
1076
|
+
function governedObjectAuthority(candidate) {
|
|
1077
|
+
if (candidate.semanticObjectType === 'metric')
|
|
1078
|
+
return 3;
|
|
1079
|
+
if (candidate.semanticObjectType === 'measure')
|
|
1080
|
+
return 2;
|
|
1081
|
+
if (candidate.semanticObjectType === 'model')
|
|
1082
|
+
return 1;
|
|
1083
|
+
return 0;
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Reduce retrieval output to genuinely DIFFERENT governed meanings.
|
|
1087
|
+
*
|
|
1088
|
+
* Two collapses, both provable from the semantic registry rather than guessed:
|
|
1089
|
+
* - A simple metric, the measure it wraps, and the model that holds them are
|
|
1090
|
+
* one meaning. Offering all three asks a person to choose between a thing
|
|
1091
|
+
* and its own wrapper.
|
|
1092
|
+
* - An entity is a join key, not an answer. When the question asks for an
|
|
1093
|
+
* attribute ("customer names") and a dimension matched, entities are not
|
|
1094
|
+
* candidate readings of it at all.
|
|
1095
|
+
*/
|
|
1096
|
+
export function collapseRedundantGovernedCandidates(question, candidates) {
|
|
1097
|
+
const byScore = [...new Map(candidates
|
|
1098
|
+
.filter((candidate) => candidate.eligible !== false && candidate.compatibility !== 'incompatible')
|
|
1099
|
+
.map((candidate) => [candidate.id, candidate])).values()].sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id));
|
|
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);
|
|
1110
|
+
const hasDimension = byScore.some((candidate) => candidate.semanticObjectType === 'dimension');
|
|
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))
|
|
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;
|
|
1161
|
+
const representatives = new Map();
|
|
1162
|
+
const passthrough = [];
|
|
1163
|
+
for (const candidate of kindFiltered) {
|
|
1164
|
+
const type = candidate.semanticObjectType;
|
|
1165
|
+
if (type !== 'metric' && type !== 'measure' && type !== 'model') {
|
|
1166
|
+
passthrough.push(candidate);
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
const key = `${(candidate.semanticModel ?? '').toLowerCase()}::${candidateLeafName(candidate)}`;
|
|
1170
|
+
const current = representatives.get(key);
|
|
1171
|
+
if (!current || governedObjectAuthority(candidate) > governedObjectAuthority(current)) {
|
|
1172
|
+
representatives.set(key, candidate);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
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));
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* The governed meaning to run when nothing proved a single exact reading.
|
|
1216
|
+
*
|
|
1217
|
+
* DQL commits to the best-ranked interpretation and discloses it through the
|
|
1218
|
+
* route label, instead of stopping to ask. Asking on every multi-candidate
|
|
1219
|
+
* question made ordinary lookups feel like an interrogation, and most of those
|
|
1220
|
+
* questions had no real ambiguity behind them.
|
|
1221
|
+
*/
|
|
1222
|
+
export function bestGovernedInterpretation(question, candidates) {
|
|
1223
|
+
// Take the best candidate that is actually executable, rather than refusing
|
|
1224
|
+
// because the top-ranked hit happens to be a descriptive modeling entity.
|
|
1225
|
+
// `compatibility: 'unknown'` is common for governed objects that execute
|
|
1226
|
+
// perfectly well, so it does not disqualify — but 'partial' does. A partial
|
|
1227
|
+
// match has already been proven NOT to cover the request, and running one
|
|
1228
|
+
// silently answers a different question than the one that was asked.
|
|
1229
|
+
return collapseRedundantGovernedCandidates(question, candidates).find((candidate) => candidate.compatibility !== 'partial'
|
|
1230
|
+
&& (candidate.kind === 'certified_block'
|
|
1231
|
+
|| candidate.kind === 'semantic_metric'
|
|
1232
|
+
|| candidate.kind === 'semantic_member'));
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* Retrieval may nominate qualified candidates, but only the resolved plan may
|
|
1236
|
+
* authorize analytical execution. If bounded meaning cannot freeze one exact
|
|
1237
|
+
* tuple, retain stable candidate identities for a focused continuation or emit
|
|
1238
|
+
* a typed modeling gap. Never hand those candidates to the legacy answer loop
|
|
1239
|
+
* as another meaning/planning authority.
|
|
1240
|
+
*/
|
|
1241
|
+
function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], question = '') {
|
|
1242
|
+
// Ask only about meanings that genuinely differ. A metric, the measure it
|
|
1243
|
+
// wraps, and their model are one reading, and a join key is not a reading of
|
|
1244
|
+
// an attribute question at all.
|
|
1245
|
+
const eligible = collapseRedundantGovernedCandidates(question, candidates);
|
|
1246
|
+
const trace = evidence ? retrievalTrace(evidence, candidates) : {
|
|
1247
|
+
candidateCount: 0,
|
|
1248
|
+
candidateIds: [],
|
|
1249
|
+
};
|
|
1250
|
+
if (eligible.length > 1) {
|
|
1251
|
+
const choices = eligible.slice(0, 3);
|
|
704
1252
|
return {
|
|
705
1253
|
...base,
|
|
706
|
-
|
|
707
|
-
|
|
1254
|
+
action: 'clarify',
|
|
1255
|
+
confidence: 1,
|
|
1256
|
+
source: 'heuristic',
|
|
1257
|
+
category: 'unclear',
|
|
1258
|
+
depth: 'quick',
|
|
1259
|
+
reason: 'Bounded retrieval found multiple governed meanings, so no analytical plan was frozen.',
|
|
1260
|
+
requiresClarification: true,
|
|
1261
|
+
clarifyingQuestion: `Which governed meaning should DQL bind: ${choices.map(renderCandidateChoice).join(' or ')}?`,
|
|
1262
|
+
clarificationOptions: buildClarificationOptions(choices),
|
|
1263
|
+
retrievalEvidence: trace,
|
|
1264
|
+
resolvedAnalyticalPlan: undefined,
|
|
1265
|
+
meaningResolution: undefined,
|
|
708
1266
|
};
|
|
709
1267
|
}
|
|
1268
|
+
const candidateIds = eligible.map((candidate) => candidate.qualifiedId ?? candidate.id);
|
|
1269
|
+
const message = candidateIds.length === 1
|
|
1270
|
+
? `The retrieved governed candidate ${candidateIds[0]} did not prove the complete requested metric, grain, filters, ordering, and outputs. Model the missing capability before retrying.`
|
|
1271
|
+
: 'No governed candidate proved the complete requested metric, grain, filters, ordering, and outputs. Model the missing capability or choose a governed identifier before retrying.';
|
|
710
1272
|
return {
|
|
711
1273
|
...base,
|
|
712
|
-
action:
|
|
713
|
-
confidence:
|
|
714
|
-
|
|
715
|
-
category:
|
|
716
|
-
|
|
1274
|
+
action: 'block',
|
|
1275
|
+
confidence: 1,
|
|
1276
|
+
source: 'heuristic',
|
|
1277
|
+
category: base.action === 'investigate' ? 'data_analysis' : 'data_lookup',
|
|
1278
|
+
depth: 'quick',
|
|
1279
|
+
reason: message,
|
|
1280
|
+
requiresClarification: false,
|
|
1281
|
+
retrievalEvidence: trace,
|
|
1282
|
+
terminalOutcome: {
|
|
1283
|
+
kind: 'modeling_gap',
|
|
1284
|
+
code: 'ANALYTICAL_MODELING_GAP',
|
|
1285
|
+
message,
|
|
1286
|
+
candidateIds,
|
|
1287
|
+
},
|
|
1288
|
+
resolvedAnalyticalPlan: undefined,
|
|
1289
|
+
meaningResolution: undefined,
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Deterministic business questions that must be settled before meaning/model
|
|
1294
|
+
* orchestration. These are not proof failures: no immutable analytical plan
|
|
1295
|
+
* exists yet, so the only valid outcome is one concise clarification.
|
|
1296
|
+
*/
|
|
1297
|
+
function deterministicPrePlanClarification(request, base, evidence, candidates) {
|
|
1298
|
+
const asksForRanking = questionTypeFromText(request.question) === 'ranking';
|
|
1299
|
+
const requestedMeasures = (evidence.parsedIntent?.measures ?? [])
|
|
1300
|
+
.map(normalizeMetricPhrase)
|
|
1301
|
+
.filter(Boolean);
|
|
1302
|
+
const hasExplicitRankingMetric = requestedMeasures.length > 0
|
|
1303
|
+
&& requestedMeasures.every((requested) => candidates.some((candidate) => candidateProvesMetricTerm(candidate, requested)))
|
|
1304
|
+
|| hasStrongQualifiedMetricEvidence(candidates)
|
|
1305
|
+
|| hasQuestionQualifiedMetricEvidence(request.question, evidence, candidates);
|
|
1306
|
+
const retrievalEvidence = retrievalTrace(evidence, candidates);
|
|
1307
|
+
const requestedDimensions = uniqueNormalizedTerms(evidence.parsedIntent?.dimensions ?? []);
|
|
1308
|
+
const modeledFilterFields = new Set((evidence.parsedIntent?.filters ?? []).flatMap((filter) => candidates.some((candidate) => isCompatibleQualifiedMember(candidate)
|
|
1309
|
+
&& candidateIdentityTerms(candidate).some((term) => metricTermsMatch(term, normalizeMetricPhrase(filter.value))))
|
|
1310
|
+
? [normalizeMetricPhrase(filter.field)]
|
|
1311
|
+
: []));
|
|
1312
|
+
const missingDimensions = requestedDimensions.filter((requested) => !modeledFilterFields.has(requested)
|
|
1313
|
+
&& !candidates.some((candidate) => candidateProvesDimensionTerm(candidate, requested)));
|
|
1314
|
+
if (missingDimensions.length > 0) {
|
|
1315
|
+
const filterValues = (evidence.parsedIntent?.filters ?? []).map((filter) => normalizeMetricPhrase(filter.value));
|
|
1316
|
+
const alternatives = candidates
|
|
1317
|
+
.filter(isCompatibleQualifiedMember)
|
|
1318
|
+
.filter((candidate) => candidateIsDeclaredDimensionAlternative(candidate, missingDimensions))
|
|
1319
|
+
.filter((candidate) => !candidateIdentityTerms(candidate).some((term) => requestedDimensions.some((requested) => metricTermsMatch(term, requested))
|
|
1320
|
+
|| filterValues.some((value) => metricTermsMatch(term, value))))
|
|
1321
|
+
.sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id))
|
|
1322
|
+
.slice(0, 3);
|
|
1323
|
+
if (alternatives.length === 0) {
|
|
1324
|
+
if (!asksForRanking || hasExplicitRankingMetric)
|
|
1325
|
+
return undefined;
|
|
1326
|
+
return bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
|
|
1327
|
+
}
|
|
1328
|
+
const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
|
|
1329
|
+
const alternativeLabels = alternatives.map(renderCandidateChoice);
|
|
1330
|
+
return {
|
|
1331
|
+
...base,
|
|
1332
|
+
action: 'clarify',
|
|
1333
|
+
confidence: 1,
|
|
1334
|
+
reason: `The requested dimension ${requestedLabel} is absent from the retrieved qualified evidence, so no analytical plan was frozen.`,
|
|
1335
|
+
source: 'heuristic',
|
|
1336
|
+
category: 'unclear',
|
|
1337
|
+
depth: 'quick',
|
|
1338
|
+
requiresClarification: true,
|
|
1339
|
+
clarifyingQuestion: alternativeLabels.length > 0
|
|
1340
|
+
? `${requestedLabel} is not modeled. Should I use ${alternativeLabels.join(' or ')} instead?`
|
|
1341
|
+
: `${requestedLabel} is not modeled. Which governed dimension should I use instead?`,
|
|
1342
|
+
retrievalEvidence,
|
|
1343
|
+
...(alternatives.length > 0 ? { clarificationOptions: buildClarificationOptions(alternatives) } : {}),
|
|
1344
|
+
resolvedAnalyticalPlan: undefined,
|
|
1345
|
+
meaningResolution: undefined,
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
if (asksForRanking && !hasExplicitRankingMetric) {
|
|
1349
|
+
return bareRankingClarification(base, retrievalEvidence, request.question, evidence, candidates);
|
|
1350
|
+
}
|
|
1351
|
+
return undefined;
|
|
1352
|
+
}
|
|
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
|
+
});
|
|
1400
|
+
return {
|
|
1401
|
+
...base,
|
|
1402
|
+
action: 'clarify',
|
|
1403
|
+
confidence: 1,
|
|
1404
|
+
reason: 'A ranking needs a positively identified governed metric before an execution capability can be selected.',
|
|
1405
|
+
source: 'heuristic',
|
|
1406
|
+
category: 'unclear',
|
|
1407
|
+
depth: 'quick',
|
|
1408
|
+
requiresClarification: true,
|
|
1409
|
+
clarifyingQuestion: 'Top by which governed metric?',
|
|
1410
|
+
retrievalEvidence,
|
|
1411
|
+
...(rankingChoices.length > 0
|
|
1412
|
+
? { clarificationOptions: buildClarificationOptions(rankingChoices) }
|
|
1413
|
+
: {}),
|
|
1414
|
+
resolvedAnalyticalPlan: undefined,
|
|
1415
|
+
meaningResolution: undefined,
|
|
717
1416
|
};
|
|
718
1417
|
}
|
|
1418
|
+
function uniqueNormalizedTerms(values) {
|
|
1419
|
+
return [...new Set(values.map(normalizeMetricPhrase).filter(Boolean))];
|
|
1420
|
+
}
|
|
1421
|
+
function isCompatibleQualifiedMember(candidate) {
|
|
1422
|
+
return candidate.kind === 'semantic_member'
|
|
1423
|
+
&& candidate.compatibility !== 'incompatible'
|
|
1424
|
+
&& Boolean(candidate.qualifiedId ?? candidate.id);
|
|
1425
|
+
}
|
|
1426
|
+
function candidateIdentityTerms(candidate) {
|
|
1427
|
+
return uniqueNormalizedTerms([
|
|
1428
|
+
candidate.id,
|
|
1429
|
+
candidate.qualifiedId ?? '',
|
|
1430
|
+
candidate.name,
|
|
1431
|
+
...(candidate.aliases ?? []),
|
|
1432
|
+
...(candidate.dimensions ?? []),
|
|
1433
|
+
]);
|
|
1434
|
+
}
|
|
1435
|
+
function candidateProvesDimensionTerm(candidate, requested) {
|
|
1436
|
+
if (candidate.compatibility === 'incompatible')
|
|
1437
|
+
return false;
|
|
1438
|
+
if (candidate.kind === 'semantic_member'
|
|
1439
|
+
&& candidateIdentityTerms(candidate).some((term) => metricTermsMatch(term, requested)))
|
|
1440
|
+
return true;
|
|
1441
|
+
const normalized = normalizeEvidenceAnalyticalCapability(candidate);
|
|
1442
|
+
return normalized.status === 'complete'
|
|
1443
|
+
&& Boolean(normalized.capability?.dimensions.some((dimension) => metricTermsMatch(normalizeMetricPhrase(dimension.dimensionId), requested)));
|
|
1444
|
+
}
|
|
1445
|
+
function candidateProvesMetricTerm(candidate, requested) {
|
|
1446
|
+
if (candidate.compatibility === 'incompatible')
|
|
1447
|
+
return false;
|
|
1448
|
+
if (candidate.kind === 'certified_block') {
|
|
1449
|
+
return candidate.compatibility === 'compatible';
|
|
1450
|
+
}
|
|
1451
|
+
if (candidate.kind !== 'semantic_metric')
|
|
1452
|
+
return false;
|
|
1453
|
+
const stableIdentity = candidate.qualifiedId ?? candidate.id;
|
|
1454
|
+
if (!stableIdentity)
|
|
1455
|
+
return false;
|
|
1456
|
+
return [candidate.name, ...(candidate.aliases ?? [])]
|
|
1457
|
+
.map(normalizeMetricPhrase)
|
|
1458
|
+
.some((term) => metricTermsMatch(term, requested));
|
|
1459
|
+
}
|
|
1460
|
+
function candidateIsDeclaredDimensionAlternative(candidate, missingDimensions) {
|
|
1461
|
+
const facts = candidate.compatibilityFacts?.map(normalizeMetricPhrase) ?? [];
|
|
1462
|
+
return missingDimensions.some((requested) => facts.includes(`alternative for ${requested}`)
|
|
1463
|
+
|| facts.includes(`dimension alternative for ${requested}`));
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* A missing optional parsed-intent projection must not erase positive metric
|
|
1467
|
+
* evidence already retrieved for the question. Only complete, compatible,
|
|
1468
|
+
* qualified semantic metrics with an exact/explicit match reason can bypass
|
|
1469
|
+
* the bare-ranking clarification; mere catalog presence is insufficient.
|
|
1470
|
+
*/
|
|
1471
|
+
function hasStrongQualifiedMetricEvidence(candidates) {
|
|
1472
|
+
return candidates.some((candidate) => {
|
|
1473
|
+
if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
|
|
1474
|
+
return false;
|
|
1475
|
+
if (!candidate.qualifiedId || !normalizeEvidenceAnalyticalCapability(candidate).capability)
|
|
1476
|
+
return false;
|
|
1477
|
+
if (candidate.exactMatch)
|
|
1478
|
+
return true;
|
|
1479
|
+
return candidate.matchReasons.some((reason) => {
|
|
1480
|
+
const normalized = normalizeMetricPhrase(reason);
|
|
1481
|
+
return /\b(?:exact|explicit)\b/.test(normalized)
|
|
1482
|
+
&& /\b(?:metric|measure|meaning|name|alias)\b/.test(normalized);
|
|
1483
|
+
});
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
function hasQuestionQualifiedMetricEvidence(question, evidence, candidates) {
|
|
1487
|
+
const questionTokens = new Set(substantiveLexicalTokens(question));
|
|
1488
|
+
const dimensionTokens = new Set(substantiveLexicalTokens((evidence.parsedIntent?.dimensions ?? []).join(' ')));
|
|
1489
|
+
return candidates.some((candidate) => {
|
|
1490
|
+
if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
|
|
1491
|
+
return false;
|
|
1492
|
+
const normalized = normalizeEvidenceAnalyticalCapability(candidate);
|
|
1493
|
+
if (normalized.status !== 'complete' || !candidate.qualifiedId)
|
|
1494
|
+
return false;
|
|
1495
|
+
return metricLexicalVariants(candidate).some((variant) => {
|
|
1496
|
+
const metricTokens = [...new Set(substantiveLexicalTokens(variant)
|
|
1497
|
+
.filter((token) => !dimensionTokens.has(token)))];
|
|
1498
|
+
if (metricTokens.length === 0)
|
|
1499
|
+
return false;
|
|
1500
|
+
const matched = metricTokens.filter((token) => questionTokens.has(token)).length;
|
|
1501
|
+
return metricTokens.length === 1
|
|
1502
|
+
? matched === 1
|
|
1503
|
+
: matched >= 2 && matched / metricTokens.length >= 0.5;
|
|
1504
|
+
});
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Compare both authored identities and their canonical local names. Generated
|
|
1509
|
+
* semantic-model namespaces remain available as authored evidence, but cannot
|
|
1510
|
+
* dilute the leaf metric phrase that users naturally ask for.
|
|
1511
|
+
*/
|
|
1512
|
+
function metricLexicalVariants(candidate) {
|
|
1513
|
+
const authored = [
|
|
1514
|
+
candidate.name,
|
|
1515
|
+
...(candidate.aliases ?? []),
|
|
1516
|
+
...(candidate.qualifiedId ? [candidate.qualifiedId] : []),
|
|
1517
|
+
].filter(Boolean);
|
|
1518
|
+
return [...new Set(authored.flatMap((value) => {
|
|
1519
|
+
const local = value.split(/[.:/]/).filter(Boolean).at(-1);
|
|
1520
|
+
return local && local !== value ? [value, local] : [value];
|
|
1521
|
+
}))];
|
|
1522
|
+
}
|
|
1523
|
+
const NON_SUBSTANTIVE_RANKING_TOKENS = new Set([
|
|
1524
|
+
'a', 'an', 'and', 'are', 'at', 'best', 'bottom', 'by', 'count', 'for',
|
|
1525
|
+
'from', 'give', 'has', 'have', 'highest', 'in', 'is', 'least', 'list',
|
|
1526
|
+
'lowest', 'me', 'measure', 'metric', 'most', 'number', 'of', 'on', 'or',
|
|
1527
|
+
'per', 'rank', 'ranked', 'ranking', 'show', 'that', 'the', 'this', 'to',
|
|
1528
|
+
'top', 'total', 'value', 'what', 'which', 'who', 'with', 'without', 'worst',
|
|
1529
|
+
'amount',
|
|
1530
|
+
]);
|
|
1531
|
+
function substantiveLexicalTokens(value) {
|
|
1532
|
+
return normalizeMetricPhrase(value)
|
|
1533
|
+
.split(' ')
|
|
1534
|
+
.filter((token) => token.length >= 2 && !NON_SUBSTANTIVE_RANKING_TOKENS.has(token));
|
|
1535
|
+
}
|
|
1536
|
+
function renderCandidateChoice(candidate) {
|
|
1537
|
+
const identity = candidate.qualifiedId ?? candidate.id;
|
|
1538
|
+
const stableName = identity.split(/[.:]/).at(-1) ?? candidate.name;
|
|
1539
|
+
// Never paste a raw semantic-layer record into the question a person reads.
|
|
1540
|
+
const description = humanizeCandidateDefinition(candidate.definition);
|
|
1541
|
+
const grain = candidate.primaryEntity?.trim();
|
|
1542
|
+
const kind = candidateKindLabel(candidate.kind);
|
|
1543
|
+
const detail = [description, grain ? `grain: ${grain}` : ''].filter(Boolean).join('; ');
|
|
1544
|
+
return detail
|
|
1545
|
+
? `${candidate.name} — ${kind}, ${detail}`
|
|
1546
|
+
: `${candidate.name} — ${kind} (${stableName})`;
|
|
1547
|
+
}
|
|
719
1548
|
/**
|
|
720
1549
|
* AGT-017 / AGT-018 / PERF-002 — provider failure must not send an otherwise
|
|
721
1550
|
* unambiguous semantic question back through the legacy answer cascade. A dbt
|
|
@@ -773,7 +1602,13 @@ function hasMateriallyRelatedCompetitor(exact, candidates) {
|
|
|
773
1602
|
const floor = Math.max(0.55, exact.relevanceScore - 0.2);
|
|
774
1603
|
return candidates.some((candidate) => candidate.id !== exact.id
|
|
775
1604
|
&& candidate.compatibility !== "incompatible"
|
|
776
|
-
&& candidate.relevanceScore >= floor
|
|
1605
|
+
&& candidate.relevanceScore >= floor
|
|
1606
|
+
// A dimension/member card can help bind the selected metric's tuple, but
|
|
1607
|
+
// it is not a competing business metric meaning. Treating it as one sends
|
|
1608
|
+
// an exact metric plus its own dimension candidates back to the resolver.
|
|
1609
|
+
&& (exact.kind !== 'semantic_metric'
|
|
1610
|
+
|| candidate.kind === 'semantic_metric'
|
|
1611
|
+
|| candidate.kind === 'certified_block'));
|
|
777
1612
|
}
|
|
778
1613
|
/**
|
|
779
1614
|
* Skip the meaning-model call only when host-owned fit checks have already
|
|
@@ -806,6 +1641,13 @@ function dominantCompatibleGovernedCandidate(candidates) {
|
|
|
806
1641
|
const hasExecutableCompetitor = compatible.some((candidate) => candidate.id !== best.id && candidate.relevanceScore >= competitorFloor);
|
|
807
1642
|
return hasExecutableCompetitor ? undefined : best;
|
|
808
1643
|
}
|
|
1644
|
+
function authoritativeExactCertifiedExample(candidates) {
|
|
1645
|
+
const exact = candidates.filter((candidate) => candidate.kind === 'certified_block'
|
|
1646
|
+
&& candidate.exactMatch
|
|
1647
|
+
&& candidate.compatibility === 'compatible'
|
|
1648
|
+
&& candidate.analyticalFitClass === 'exact');
|
|
1649
|
+
return exact.length === 1 ? exact[0] : undefined;
|
|
1650
|
+
}
|
|
809
1651
|
function shouldDeferCompositionalFollowUpToExecutor(base, candidates) {
|
|
810
1652
|
if (!base.followsUp)
|
|
811
1653
|
return false;
|
|
@@ -848,6 +1690,7 @@ export function createHybridRouter(options = {}) {
|
|
|
848
1690
|
const threshold = options.llmThreshold ?? DEFAULT_THRESHOLD;
|
|
849
1691
|
const cacheSize = options.cacheSize ?? DEFAULT_CACHE_SIZE;
|
|
850
1692
|
const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS;
|
|
1693
|
+
const requireMeaningCall = options.requireMeaningCallForNaturalLanguage ?? true;
|
|
851
1694
|
const cache = new Map();
|
|
852
1695
|
let tick = 0;
|
|
853
1696
|
const now = options.now ?? (() => { tick += 1; return tick; });
|
|
@@ -878,6 +1721,10 @@ export function createHybridRouter(options = {}) {
|
|
|
878
1721
|
if (base.action === "converse" || base.action === "compose_app") {
|
|
879
1722
|
return { ...base, source: base.source ?? "heuristic" };
|
|
880
1723
|
}
|
|
1724
|
+
const initialDiscoveryRoute = discoveryRouteBeforeRetrieval(request, base);
|
|
1725
|
+
if (request.runBudget && !request.runBudget.mayStartDiscovery(initialDiscoveryRoute)) {
|
|
1726
|
+
return softBoundaryDecision(request, base, initialDiscoveryRoute);
|
|
1727
|
+
}
|
|
881
1728
|
let evidence;
|
|
882
1729
|
if (options.getEvidence) {
|
|
883
1730
|
try {
|
|
@@ -890,38 +1737,93 @@ export function createHybridRouter(options = {}) {
|
|
|
890
1737
|
}
|
|
891
1738
|
}
|
|
892
1739
|
if (evidence) {
|
|
1740
|
+
evidence = {
|
|
1741
|
+
...evidence,
|
|
1742
|
+
candidates: canonicalizeMetricMeasureCandidates(evidence.candidates),
|
|
1743
|
+
...(evidence.clarificationCandidates
|
|
1744
|
+
? { clarificationCandidates: canonicalizeMetricMeasureCandidates([
|
|
1745
|
+
...evidence.candidates,
|
|
1746
|
+
...evidence.clarificationCandidates,
|
|
1747
|
+
]).filter((candidate) => evidence.clarificationCandidates.some((item) => item.id === candidate.id)) }
|
|
1748
|
+
: {}),
|
|
1749
|
+
};
|
|
893
1750
|
let candidates = buildMeaningEvidencePackage(evidence, options.maxMeaningCandidates ?? 12);
|
|
894
1751
|
// A structured clarification selection is authoritative identity input,
|
|
895
1752
|
// not a new fuzzy-search phrase. Keep it in the bounded package even if
|
|
896
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.
|
|
897
1759
|
const selectedEvidence = request.selectedEvidenceId
|
|
898
|
-
? evidence.candidates
|
|
1760
|
+
? [...evidence.candidates, ...(evidence.clarificationCandidates ?? [])]
|
|
1761
|
+
.find((candidate) => candidate.id === request.selectedEvidenceId && candidate.eligible !== false)
|
|
899
1762
|
: undefined;
|
|
900
1763
|
if (selectedEvidence && !candidates.some((candidate) => candidate.id === selectedEvidence.id)) {
|
|
901
1764
|
candidates = [selectedEvidence, ...candidates.filter((candidate) => candidate.id !== selectedEvidence.id)]
|
|
902
1765
|
.slice(0, options.maxMeaningCandidates ?? 12);
|
|
903
1766
|
}
|
|
904
1767
|
if (candidates.length > 0) {
|
|
1768
|
+
// Clarification is local and never provider-bound, so it can inspect the
|
|
1769
|
+
// complete already-retrieved set. Keep the smaller package below for
|
|
1770
|
+
// any later meaning call.
|
|
1771
|
+
const clarificationCandidates = [
|
|
1772
|
+
...evidence.candidates,
|
|
1773
|
+
...(evidence.clarificationCandidates ?? []),
|
|
1774
|
+
].filter((candidate, index, all) => candidate.eligible !== false && all.findIndex((other) => other.id === candidate.id) === index);
|
|
905
1775
|
const explicit = selectedEvidence ?? findExplicitEvidenceReference(request.question, candidates);
|
|
906
|
-
|
|
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
|
+
}
|
|
907
1799
|
const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
908
1800
|
return selectedEvidence && decision.requiresClarification
|
|
909
1801
|
? continueCascadeAfterIncompleteSelection(base, evidence, candidates, selectedEvidence)
|
|
910
1802
|
: decision;
|
|
911
1803
|
}
|
|
912
|
-
const multiMetricPrimary =
|
|
1804
|
+
const multiMetricPrimary = !shouldUseMeaningCall
|
|
1805
|
+
? exactMultiMetricPrimary(request.question, evidence, candidates)
|
|
1806
|
+
: undefined;
|
|
913
1807
|
if (multiMetricPrimary) {
|
|
914
1808
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
915
1809
|
}
|
|
916
|
-
const
|
|
1810
|
+
const authoredExample = !shouldUseMeaningCall
|
|
1811
|
+
? authoritativeExactCertifiedExample(candidates)
|
|
1812
|
+
: undefined;
|
|
1813
|
+
if (authoredExample) {
|
|
1814
|
+
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, authoredExample, candidates), 'heuristic', request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
1815
|
+
}
|
|
1816
|
+
const exactCompatible = !shouldUseMeaningCall ? candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible") : [];
|
|
917
1817
|
if (exactCompatible.length === 1 && !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
|
|
918
1818
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
919
1819
|
}
|
|
920
|
-
const dominant =
|
|
1820
|
+
const dominant = !shouldUseMeaningCall
|
|
1821
|
+
? dominantCompatibleGovernedCandidate(candidates)
|
|
1822
|
+
: undefined;
|
|
921
1823
|
if (dominant) {
|
|
922
1824
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, dominant, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
923
1825
|
}
|
|
924
|
-
if (shouldDeferCompositionalFollowUpToExecutor(base, candidates)) {
|
|
1826
|
+
if (!shouldUseMeaningCall && shouldDeferCompositionalFollowUpToExecutor(base, candidates)) {
|
|
925
1827
|
return routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative');
|
|
926
1828
|
}
|
|
927
1829
|
const key = cacheKey(request, evidence);
|
|
@@ -929,12 +1831,29 @@ export function createHybridRouter(options = {}) {
|
|
|
929
1831
|
if (cached && (options.cacheTtlMs === undefined || now() - cached.at < cacheTtlMs)) {
|
|
930
1832
|
return { ...cached.decision, source: "cache" };
|
|
931
1833
|
}
|
|
1834
|
+
// Distinguish "the resolver could not run" from "the resolver ran and
|
|
1835
|
+
// froze nothing". Only the first is a reason to refuse to interpret:
|
|
1836
|
+
// with the semantic judgment unavailable, lexical rank alone must not
|
|
1837
|
+
// settle two genuinely different metrics (AGT-017). When the resolver
|
|
1838
|
+
// did run, committing to the best governed reading is the whole point.
|
|
1839
|
+
let meaningResolverReachable = true;
|
|
932
1840
|
try {
|
|
1841
|
+
if (request.runBudget && !request.runBudget.mayStartDiscovery('clarify')) {
|
|
1842
|
+
return softBoundaryDecision(request, base, 'clarify');
|
|
1843
|
+
}
|
|
933
1844
|
const resolution = options.resolveMeaning
|
|
934
1845
|
? await options.resolveMeaning({
|
|
935
1846
|
question: request.question,
|
|
936
1847
|
history: effectiveConversationHistory(request),
|
|
937
|
-
evidence
|
|
1848
|
+
// The resolver/provider receives the same bounded evidence
|
|
1849
|
+
// package as its candidate argument. Supplemental qualified
|
|
1850
|
+
// cards are a host-only clarification aid and must not leak
|
|
1851
|
+
// through this richer carrier.
|
|
1852
|
+
evidence: {
|
|
1853
|
+
...evidence,
|
|
1854
|
+
candidates,
|
|
1855
|
+
clarificationCandidates: undefined,
|
|
1856
|
+
},
|
|
938
1857
|
candidates,
|
|
939
1858
|
signal: request.signal ?? options.signal,
|
|
940
1859
|
})
|
|
@@ -948,26 +1867,77 @@ export function createHybridRouter(options = {}) {
|
|
|
948
1867
|
if (resolution) {
|
|
949
1868
|
const validated = validateMeaningResolution(resolution, candidates);
|
|
950
1869
|
if (validated.ok) {
|
|
951
|
-
|
|
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'));
|
|
952
1919
|
}
|
|
953
1920
|
const invalidResolution = {
|
|
954
1921
|
interpretedQuestion: request.question,
|
|
955
1922
|
questionType: questionTypeFromText(request.question),
|
|
956
|
-
selectedConceptIds:
|
|
957
|
-
|
|
1923
|
+
selectedConceptIds: resolution.selectedConceptIds,
|
|
1924
|
+
recommendedExecutionId: resolution.recommendedExecutionId,
|
|
1925
|
+
queryIntent: resolution.queryIntent,
|
|
958
1926
|
rejectedCandidates: [],
|
|
959
1927
|
confidence: "low",
|
|
960
1928
|
missingInformation: [validated.reason],
|
|
961
1929
|
recommendedRoute: "clarify",
|
|
962
|
-
|
|
1930
|
+
compatibilityOutcome: 'modeling_gap',
|
|
1931
|
+
compatibilityFailures: [{
|
|
1932
|
+
code: 'INVALID_EVIDENCE_REFERENCE',
|
|
1933
|
+
field: 'meaningResolution',
|
|
1934
|
+
message: validated.reason,
|
|
1935
|
+
candidateIds: [],
|
|
1936
|
+
}],
|
|
963
1937
|
};
|
|
964
1938
|
const invalidDecision = routeDecisionForResolution(base, evidence, candidates, invalidResolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
965
1939
|
return remember(key, {
|
|
966
1940
|
...invalidDecision,
|
|
967
|
-
action: 'answer',
|
|
968
|
-
requiresClarification: false,
|
|
969
|
-
clarificationOptions: undefined,
|
|
970
|
-
clarifyingQuestion: undefined,
|
|
971
1941
|
meaningResolutionErrorCode: 'invalid_evidence_reference',
|
|
972
1942
|
});
|
|
973
1943
|
}
|
|
@@ -976,8 +1946,17 @@ export function createHybridRouter(options = {}) {
|
|
|
976
1946
|
rethrowCancellation(error, request.signal, options.signal);
|
|
977
1947
|
// A resolver transport/parse failure falls back without losing the
|
|
978
1948
|
// retrieval signal or permitting a general-knowledge misroute.
|
|
1949
|
+
meaningResolverReachable = false;
|
|
979
1950
|
}
|
|
980
|
-
|
|
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;
|
|
981
1960
|
}
|
|
982
1961
|
}
|
|
983
1962
|
// Legacy/no-evidence path. A confident analytical heuristic stays offline;
|
|
@@ -987,6 +1966,9 @@ export function createHybridRouter(options = {}) {
|
|
|
987
1966
|
return { ...base, source: base.source ?? "heuristic" };
|
|
988
1967
|
}
|
|
989
1968
|
let catalogContext;
|
|
1969
|
+
if (request.runBudget && !request.runBudget.mayStartDiscovery('clarify')) {
|
|
1970
|
+
return softBoundaryDecision(request, base, 'clarify');
|
|
1971
|
+
}
|
|
990
1972
|
try {
|
|
991
1973
|
catalogContext = options.getCatalogContext ? await options.getCatalogContext(request) : undefined;
|
|
992
1974
|
}
|
|
@@ -1017,5 +1999,30 @@ export function createHybridRouter(options = {}) {
|
|
|
1017
1999
|
},
|
|
1018
2000
|
};
|
|
1019
2001
|
}
|
|
2002
|
+
function discoveryRouteBeforeRetrieval(request, base) {
|
|
2003
|
+
if (request.runBudget?.mode === 'research' || request.requestedMode === 'research')
|
|
2004
|
+
return 'research';
|
|
2005
|
+
if (base.action === 'clarify' || base.requiresClarification || request.signals?.missingContext?.length)
|
|
2006
|
+
return 'clarify';
|
|
2007
|
+
if (request.intent === 'exact_certified_lookup'
|
|
2008
|
+
|| (request.signals?.certifiedScore ?? 0) >= 0.5
|
|
2009
|
+
|| (request.signals?.metricScore ?? 0) >= 0.5)
|
|
2010
|
+
return 'semantic_answer';
|
|
2011
|
+
return 'generated_answer';
|
|
2012
|
+
}
|
|
2013
|
+
function softBoundaryDecision(request, base, route) {
|
|
2014
|
+
const seconds = Math.round((request.runBudget?.softTargetMs(route) ?? 15_000) / 1_000);
|
|
2015
|
+
return {
|
|
2016
|
+
...base,
|
|
2017
|
+
action: 'clarify',
|
|
2018
|
+
confidence: 1,
|
|
2019
|
+
source: 'heuristic',
|
|
2020
|
+
requiresClarification: true,
|
|
2021
|
+
reason: `The ${seconds}-second discovery target elapsed before a plan was frozen, so DQL did not start another retrieval or provider branch.`,
|
|
2022
|
+
clarifyingQuestion: request.runBudget?.mode === 'research'
|
|
2023
|
+
? 'Research has stopped starting new branches. Would you like to narrow the question and retry?'
|
|
2024
|
+
: 'The discovery window ended before an exact plan was frozen. Which metric or grain should DQL use on retry?',
|
|
2025
|
+
};
|
|
2026
|
+
}
|
|
1020
2027
|
export { intentForCategory, parseMeaningResolution };
|
|
1021
2028
|
//# sourceMappingURL=router.js.map
|