@duckcodeailabs/dql-agent 1.13.4 → 1.13.5
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 +66 -3
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +441 -28
- package/dist/agent-run-engine.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-result-facts.d.ts +53 -0
- package/dist/analytical-result-facts.d.ts.map +1 -1
- package/dist/analytical-result-facts.js +144 -0
- package/dist/analytical-result-facts.js.map +1 -1
- package/dist/answer-loop.d.ts +40 -3
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +681 -37
- 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/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 +9 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -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 +23 -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/meaning-evidence.d.ts.map +1 -1
- package/dist/metadata/meaning-evidence.js +323 -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 +369 -27
- package/dist/resolved-analytical-plan.js.map +1 -1
- package/dist/router.d.ts +30 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +705 -44
- 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"
|
|
@@ -594,20 +794,43 @@ function buildEvidenceClarification(candidates, missing = []) {
|
|
|
594
794
|
return "Which governed business meaning should I use for this question?";
|
|
595
795
|
}
|
|
596
796
|
function directResolution(request, evidence, candidate, candidates) {
|
|
797
|
+
const inferredQuestionType = questionTypeFromText(request.question);
|
|
798
|
+
const questionType = inferredQuestionType === 'definition'
|
|
799
|
+
&& candidate.kind === 'semantic_metric'
|
|
800
|
+
&& candidate.exactMatch
|
|
801
|
+
&& Boolean(normalizeEvidenceAnalyticalCapability(candidate).capability)
|
|
802
|
+
&& /^\s*what (?:is|was|were|are)\b/i.test(request.question)
|
|
803
|
+
&& !/\b(?:define|definition|meaning|mean)\b/i.test(request.question)
|
|
804
|
+
? 'value'
|
|
805
|
+
: inferredQuestionType;
|
|
597
806
|
const metricCandidates = explicitlyRequestedMetricCandidates(request.question, evidence, candidate, candidates);
|
|
598
807
|
const analyticalFrame = buildDeterministicAnalyticalFrame({
|
|
599
808
|
question: request.question,
|
|
809
|
+
questionType,
|
|
600
810
|
evidence,
|
|
601
811
|
metricCandidate: candidate,
|
|
602
812
|
metricCandidates,
|
|
603
813
|
candidates,
|
|
604
814
|
});
|
|
815
|
+
const queryIntent = defaultQueryIntent(evidence);
|
|
816
|
+
const memberCandidates = candidates.filter((item) => {
|
|
817
|
+
if (item.kind !== 'semantic_member' || item.compatibility === 'incompatible')
|
|
818
|
+
return false;
|
|
819
|
+
const identities = [item.name, ...(item.aliases ?? [])].map(normalizeMetricPhrase).filter(Boolean);
|
|
820
|
+
return queryIntent.filters.some((filter) => identities.includes(normalizeMetricPhrase(filter.value)));
|
|
821
|
+
});
|
|
822
|
+
const canonicalFilters = queryIntent.filters.map((filter) => {
|
|
823
|
+
const member = memberCandidates.find((item) => [item.name, ...(item.aliases ?? [])]
|
|
824
|
+
.map(normalizeMetricPhrase)
|
|
825
|
+
.includes(normalizeMetricPhrase(filter.value)));
|
|
826
|
+
return member ? { ...filter, value: member.name } : filter;
|
|
827
|
+
});
|
|
605
828
|
return {
|
|
606
829
|
interpretedQuestion: request.question,
|
|
607
|
-
questionType
|
|
608
|
-
selectedConceptIds: metricCandidates.map((
|
|
830
|
+
questionType,
|
|
831
|
+
selectedConceptIds: [...metricCandidates, ...memberCandidates].map((item) => item.id),
|
|
609
832
|
recommendedExecutionId: candidate.id,
|
|
610
|
-
queryIntent:
|
|
833
|
+
queryIntent: { ...queryIntent, filters: canonicalFilters },
|
|
611
834
|
rejectedCandidates: [],
|
|
612
835
|
confidence: "high",
|
|
613
836
|
missingInformation: [],
|
|
@@ -623,6 +846,12 @@ function explicitlyRequestedMetricCandidates(question, evidence, primary, candid
|
|
|
623
846
|
// representations of the same request, not additional requested metrics.
|
|
624
847
|
if (requestedTerms.length <= 1)
|
|
625
848
|
return [primary];
|
|
849
|
+
const primaryNames = [primary.name, ...(primary.aliases ?? [])]
|
|
850
|
+
.map(normalizeMetricPhrase)
|
|
851
|
+
.filter(Boolean);
|
|
852
|
+
if (requestedTerms.every((term) => primaryNames.some((name) => metricTermsMatch(name, term)))) {
|
|
853
|
+
return [primary];
|
|
854
|
+
}
|
|
626
855
|
const questionText = normalizeMetricPhrase(question);
|
|
627
856
|
const metrics = candidates.filter((candidate) => {
|
|
628
857
|
if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
|
|
@@ -646,6 +875,7 @@ function explicitlyRequestedMetricCandidates(question, evidence, primary, candid
|
|
|
646
875
|
function normalizeMetricPhrase(value) {
|
|
647
876
|
return value
|
|
648
877
|
.toLowerCase()
|
|
878
|
+
.replace(/%/g, ' percentage ')
|
|
649
879
|
.replace(/[_./:-]+/g, ' ')
|
|
650
880
|
.replace(/[^a-z0-9 ]+/g, ' ')
|
|
651
881
|
.replace(/\s+/g, ' ')
|
|
@@ -686,7 +916,16 @@ function exactMultiMetricPrimary(question, evidence, candidates) {
|
|
|
686
916
|
? primary
|
|
687
917
|
: undefined;
|
|
688
918
|
}
|
|
689
|
-
function routeWithoutMeaningModel(request, base, evidence, candidates, planMode = 'authoritative'
|
|
919
|
+
function routeWithoutMeaningModel(request, base, evidence, candidates, planMode = 'authoritative',
|
|
920
|
+
/**
|
|
921
|
+
* Whether DQL may commit to the best-ranked reading on the user's behalf.
|
|
922
|
+
*
|
|
923
|
+
* False when the meaning resolver never got to run (provider outage). Two
|
|
924
|
+
* genuinely different metrics that merely share an alias — `booked_revenue`
|
|
925
|
+
* and `billed_revenue` both aliased "revenue" — must not be settled by
|
|
926
|
+
* lexical rank alone with the semantic judgment switched off. AGT-017.
|
|
927
|
+
*/
|
|
928
|
+
mayAssumeInterpretation = true) {
|
|
690
929
|
const multiMetricPrimary = exactMultiMetricPrimary(request.question, evidence, candidates);
|
|
691
930
|
if (multiMetricPrimary) {
|
|
692
931
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, planMode);
|
|
@@ -700,22 +939,354 @@ function routeWithoutMeaningModel(request, base, evidence, candidates, planMode
|
|
|
700
939
|
if (semanticMetric) {
|
|
701
940
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, semanticMetric, candidates), "heuristic", request.question, planMode);
|
|
702
941
|
}
|
|
703
|
-
|
|
942
|
+
// Last resort before asking the user: commit to the best governed reading.
|
|
943
|
+
// Clarifying whenever retrieval returned more than one candidate meant a
|
|
944
|
+
// `type: simple` metric, the measure it wraps, and the model that holds them
|
|
945
|
+
// were offered as three competing "meanings" of the same number.
|
|
946
|
+
const best = mayAssumeInterpretation
|
|
947
|
+
? bestGovernedInterpretation(request.question, candidates)
|
|
948
|
+
: undefined;
|
|
949
|
+
if (best) {
|
|
950
|
+
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, best, candidates), "heuristic", request.question, planMode);
|
|
951
|
+
}
|
|
952
|
+
return unresolvedAnalyticalPlanDecision(base, evidence, candidates, request.question);
|
|
953
|
+
}
|
|
954
|
+
/** Leaf identity of a governed candidate, ignoring its source qualification. */
|
|
955
|
+
function candidateLeafName(candidate) {
|
|
956
|
+
const identity = candidate.qualifiedId ?? candidate.id;
|
|
957
|
+
return (identity.split(/[.:]/).at(-1) ?? candidate.name).trim().toLowerCase();
|
|
958
|
+
}
|
|
959
|
+
/** Prefer the most authoritative representative of one underlying meaning. */
|
|
960
|
+
function governedObjectAuthority(candidate) {
|
|
961
|
+
if (candidate.semanticObjectType === 'metric')
|
|
962
|
+
return 3;
|
|
963
|
+
if (candidate.semanticObjectType === 'measure')
|
|
964
|
+
return 2;
|
|
965
|
+
if (candidate.semanticObjectType === 'model')
|
|
966
|
+
return 1;
|
|
967
|
+
return 0;
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Reduce retrieval output to genuinely DIFFERENT governed meanings.
|
|
971
|
+
*
|
|
972
|
+
* Two collapses, both provable from the semantic registry rather than guessed:
|
|
973
|
+
* - A simple metric, the measure it wraps, and the model that holds them are
|
|
974
|
+
* one meaning. Offering all three asks a person to choose between a thing
|
|
975
|
+
* and its own wrapper.
|
|
976
|
+
* - An entity is a join key, not an answer. When the question asks for an
|
|
977
|
+
* attribute ("customer names") and a dimension matched, entities are not
|
|
978
|
+
* candidate readings of it at all.
|
|
979
|
+
*/
|
|
980
|
+
export function collapseRedundantGovernedCandidates(question, candidates) {
|
|
981
|
+
const byScore = [...new Map(candidates
|
|
982
|
+
.filter((candidate) => candidate.eligible !== false && candidate.compatibility !== 'incompatible')
|
|
983
|
+
.map((candidate) => [candidate.id, candidate])).values()].sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id));
|
|
984
|
+
const wantsAttribute = /\b(names?|labels?|titles?|descriptions?)\b/i.test(question);
|
|
985
|
+
const hasDimension = byScore.some((candidate) => candidate.semanticObjectType === 'dimension');
|
|
986
|
+
const kindFiltered = wantsAttribute && hasDimension
|
|
987
|
+
? byScore.filter((candidate) => candidate.semanticObjectType !== 'entity')
|
|
988
|
+
: byScore;
|
|
989
|
+
const representatives = new Map();
|
|
990
|
+
const passthrough = [];
|
|
991
|
+
for (const candidate of kindFiltered) {
|
|
992
|
+
const type = candidate.semanticObjectType;
|
|
993
|
+
if (type !== 'metric' && type !== 'measure' && type !== 'model') {
|
|
994
|
+
passthrough.push(candidate);
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
const key = `${(candidate.semanticModel ?? '').toLowerCase()}::${candidateLeafName(candidate)}`;
|
|
998
|
+
const current = representatives.get(key);
|
|
999
|
+
if (!current || governedObjectAuthority(candidate) > governedObjectAuthority(current)) {
|
|
1000
|
+
representatives.set(key, candidate);
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
return [...passthrough, ...representatives.values()].sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id));
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* The governed meaning to run when nothing proved a single exact reading.
|
|
1007
|
+
*
|
|
1008
|
+
* DQL commits to the best-ranked interpretation and discloses it through the
|
|
1009
|
+
* route label, instead of stopping to ask. Asking on every multi-candidate
|
|
1010
|
+
* question made ordinary lookups feel like an interrogation, and most of those
|
|
1011
|
+
* questions had no real ambiguity behind them.
|
|
1012
|
+
*/
|
|
1013
|
+
export function bestGovernedInterpretation(question, candidates) {
|
|
1014
|
+
// Take the best candidate that is actually executable, rather than refusing
|
|
1015
|
+
// because the top-ranked hit happens to be a descriptive modeling entity.
|
|
1016
|
+
// `compatibility: 'unknown'` is common for governed objects that execute
|
|
1017
|
+
// perfectly well, so it does not disqualify — but 'partial' does. A partial
|
|
1018
|
+
// match has already been proven NOT to cover the request, and running one
|
|
1019
|
+
// silently answers a different question than the one that was asked.
|
|
1020
|
+
return collapseRedundantGovernedCandidates(question, candidates).find((candidate) => candidate.compatibility !== 'partial'
|
|
1021
|
+
&& (candidate.kind === 'certified_block'
|
|
1022
|
+
|| candidate.kind === 'semantic_metric'
|
|
1023
|
+
|| candidate.kind === 'semantic_member'));
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Retrieval may nominate qualified candidates, but only the resolved plan may
|
|
1027
|
+
* authorize analytical execution. If bounded meaning cannot freeze one exact
|
|
1028
|
+
* tuple, retain stable candidate identities for a focused continuation or emit
|
|
1029
|
+
* a typed modeling gap. Never hand those candidates to the legacy answer loop
|
|
1030
|
+
* as another meaning/planning authority.
|
|
1031
|
+
*/
|
|
1032
|
+
function unresolvedAnalyticalPlanDecision(base, evidence, candidates = [], question = '') {
|
|
1033
|
+
// Ask only about meanings that genuinely differ. A metric, the measure it
|
|
1034
|
+
// wraps, and their model are one reading, and a join key is not a reading of
|
|
1035
|
+
// an attribute question at all.
|
|
1036
|
+
const eligible = collapseRedundantGovernedCandidates(question, candidates);
|
|
1037
|
+
const trace = evidence ? retrievalTrace(evidence, candidates) : {
|
|
1038
|
+
candidateCount: 0,
|
|
1039
|
+
candidateIds: [],
|
|
1040
|
+
};
|
|
1041
|
+
if (eligible.length > 1) {
|
|
1042
|
+
const choices = eligible.slice(0, 3);
|
|
704
1043
|
return {
|
|
705
1044
|
...base,
|
|
706
|
-
|
|
707
|
-
|
|
1045
|
+
action: 'clarify',
|
|
1046
|
+
confidence: 1,
|
|
1047
|
+
source: 'heuristic',
|
|
1048
|
+
category: 'unclear',
|
|
1049
|
+
depth: 'quick',
|
|
1050
|
+
reason: 'Bounded retrieval found multiple governed meanings, so no analytical plan was frozen.',
|
|
1051
|
+
requiresClarification: true,
|
|
1052
|
+
clarifyingQuestion: `Which governed meaning should DQL bind: ${choices.map(renderCandidateChoice).join(' or ')}?`,
|
|
1053
|
+
clarificationOptions: buildClarificationOptions(choices),
|
|
1054
|
+
retrievalEvidence: trace,
|
|
1055
|
+
resolvedAnalyticalPlan: undefined,
|
|
1056
|
+
meaningResolution: undefined,
|
|
708
1057
|
};
|
|
709
1058
|
}
|
|
1059
|
+
const candidateIds = eligible.map((candidate) => candidate.qualifiedId ?? candidate.id);
|
|
1060
|
+
const message = candidateIds.length === 1
|
|
1061
|
+
? `The retrieved governed candidate ${candidateIds[0]} did not prove the complete requested metric, grain, filters, ordering, and outputs. Model the missing capability before retrying.`
|
|
1062
|
+
: '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
1063
|
return {
|
|
711
1064
|
...base,
|
|
712
|
-
action:
|
|
713
|
-
confidence:
|
|
714
|
-
|
|
715
|
-
category:
|
|
716
|
-
|
|
1065
|
+
action: 'block',
|
|
1066
|
+
confidence: 1,
|
|
1067
|
+
source: 'heuristic',
|
|
1068
|
+
category: base.action === 'investigate' ? 'data_analysis' : 'data_lookup',
|
|
1069
|
+
depth: 'quick',
|
|
1070
|
+
reason: message,
|
|
1071
|
+
requiresClarification: false,
|
|
1072
|
+
retrievalEvidence: trace,
|
|
1073
|
+
terminalOutcome: {
|
|
1074
|
+
kind: 'modeling_gap',
|
|
1075
|
+
code: 'ANALYTICAL_MODELING_GAP',
|
|
1076
|
+
message,
|
|
1077
|
+
candidateIds,
|
|
1078
|
+
},
|
|
1079
|
+
resolvedAnalyticalPlan: undefined,
|
|
1080
|
+
meaningResolution: undefined,
|
|
717
1081
|
};
|
|
718
1082
|
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Deterministic business questions that must be settled before meaning/model
|
|
1085
|
+
* orchestration. These are not proof failures: no immutable analytical plan
|
|
1086
|
+
* exists yet, so the only valid outcome is one concise clarification.
|
|
1087
|
+
*/
|
|
1088
|
+
function deterministicPrePlanClarification(request, base, evidence, candidates) {
|
|
1089
|
+
const asksForRanking = questionTypeFromText(request.question) === 'ranking';
|
|
1090
|
+
const requestedMeasures = (evidence.parsedIntent?.measures ?? [])
|
|
1091
|
+
.map(normalizeMetricPhrase)
|
|
1092
|
+
.filter(Boolean);
|
|
1093
|
+
const hasExplicitRankingMetric = requestedMeasures.length > 0
|
|
1094
|
+
&& requestedMeasures.every((requested) => candidates.some((candidate) => candidateProvesMetricTerm(candidate, requested)))
|
|
1095
|
+
|| hasStrongQualifiedMetricEvidence(candidates)
|
|
1096
|
+
|| hasQuestionQualifiedMetricEvidence(request.question, evidence, candidates);
|
|
1097
|
+
const retrievalEvidence = retrievalTrace(evidence, candidates);
|
|
1098
|
+
const requestedDimensions = uniqueNormalizedTerms(evidence.parsedIntent?.dimensions ?? []);
|
|
1099
|
+
const modeledFilterFields = new Set((evidence.parsedIntent?.filters ?? []).flatMap((filter) => candidates.some((candidate) => isCompatibleQualifiedMember(candidate)
|
|
1100
|
+
&& candidateIdentityTerms(candidate).some((term) => metricTermsMatch(term, normalizeMetricPhrase(filter.value))))
|
|
1101
|
+
? [normalizeMetricPhrase(filter.field)]
|
|
1102
|
+
: []));
|
|
1103
|
+
const missingDimensions = requestedDimensions.filter((requested) => !modeledFilterFields.has(requested)
|
|
1104
|
+
&& !candidates.some((candidate) => candidateProvesDimensionTerm(candidate, requested)));
|
|
1105
|
+
if (missingDimensions.length > 0) {
|
|
1106
|
+
const filterValues = (evidence.parsedIntent?.filters ?? []).map((filter) => normalizeMetricPhrase(filter.value));
|
|
1107
|
+
const alternatives = candidates
|
|
1108
|
+
.filter(isCompatibleQualifiedMember)
|
|
1109
|
+
.filter((candidate) => candidateIsDeclaredDimensionAlternative(candidate, missingDimensions))
|
|
1110
|
+
.filter((candidate) => !candidateIdentityTerms(candidate).some((term) => requestedDimensions.some((requested) => metricTermsMatch(term, requested))
|
|
1111
|
+
|| filterValues.some((value) => metricTermsMatch(term, value))))
|
|
1112
|
+
.sort((left, right) => right.relevanceScore - left.relevanceScore || left.id.localeCompare(right.id))
|
|
1113
|
+
.slice(0, 3);
|
|
1114
|
+
if (alternatives.length === 0) {
|
|
1115
|
+
if (!asksForRanking || hasExplicitRankingMetric)
|
|
1116
|
+
return undefined;
|
|
1117
|
+
return bareRankingClarification(base, retrievalEvidence);
|
|
1118
|
+
}
|
|
1119
|
+
const requestedLabel = missingDimensions.map((term) => `“${term}”`).join(' and ');
|
|
1120
|
+
const alternativeLabels = alternatives.map(renderCandidateChoice);
|
|
1121
|
+
return {
|
|
1122
|
+
...base,
|
|
1123
|
+
action: 'clarify',
|
|
1124
|
+
confidence: 1,
|
|
1125
|
+
reason: `The requested dimension ${requestedLabel} is absent from the retrieved qualified evidence, so no analytical plan was frozen.`,
|
|
1126
|
+
source: 'heuristic',
|
|
1127
|
+
category: 'unclear',
|
|
1128
|
+
depth: 'quick',
|
|
1129
|
+
requiresClarification: true,
|
|
1130
|
+
clarifyingQuestion: alternativeLabels.length > 0
|
|
1131
|
+
? `${requestedLabel} is not modeled. Should I use ${alternativeLabels.join(' or ')} instead?`
|
|
1132
|
+
: `${requestedLabel} is not modeled. Which governed dimension should I use instead?`,
|
|
1133
|
+
retrievalEvidence,
|
|
1134
|
+
...(alternatives.length > 0 ? { clarificationOptions: buildClarificationOptions(alternatives) } : {}),
|
|
1135
|
+
resolvedAnalyticalPlan: undefined,
|
|
1136
|
+
meaningResolution: undefined,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
if (asksForRanking && !hasExplicitRankingMetric) {
|
|
1140
|
+
return bareRankingClarification(base, retrievalEvidence);
|
|
1141
|
+
}
|
|
1142
|
+
return undefined;
|
|
1143
|
+
}
|
|
1144
|
+
function bareRankingClarification(base, retrievalEvidence) {
|
|
1145
|
+
return {
|
|
1146
|
+
...base,
|
|
1147
|
+
action: 'clarify',
|
|
1148
|
+
confidence: 1,
|
|
1149
|
+
reason: 'A ranking needs a positively identified governed metric before an execution capability can be selected.',
|
|
1150
|
+
source: 'heuristic',
|
|
1151
|
+
category: 'unclear',
|
|
1152
|
+
depth: 'quick',
|
|
1153
|
+
requiresClarification: true,
|
|
1154
|
+
clarifyingQuestion: 'Top by which governed metric?',
|
|
1155
|
+
retrievalEvidence,
|
|
1156
|
+
resolvedAnalyticalPlan: undefined,
|
|
1157
|
+
meaningResolution: undefined,
|
|
1158
|
+
};
|
|
1159
|
+
}
|
|
1160
|
+
function uniqueNormalizedTerms(values) {
|
|
1161
|
+
return [...new Set(values.map(normalizeMetricPhrase).filter(Boolean))];
|
|
1162
|
+
}
|
|
1163
|
+
function isCompatibleQualifiedMember(candidate) {
|
|
1164
|
+
return candidate.kind === 'semantic_member'
|
|
1165
|
+
&& candidate.compatibility !== 'incompatible'
|
|
1166
|
+
&& Boolean(candidate.qualifiedId ?? candidate.id);
|
|
1167
|
+
}
|
|
1168
|
+
function candidateIdentityTerms(candidate) {
|
|
1169
|
+
return uniqueNormalizedTerms([
|
|
1170
|
+
candidate.id,
|
|
1171
|
+
candidate.qualifiedId ?? '',
|
|
1172
|
+
candidate.name,
|
|
1173
|
+
...(candidate.aliases ?? []),
|
|
1174
|
+
...(candidate.dimensions ?? []),
|
|
1175
|
+
]);
|
|
1176
|
+
}
|
|
1177
|
+
function candidateProvesDimensionTerm(candidate, requested) {
|
|
1178
|
+
if (candidate.compatibility === 'incompatible')
|
|
1179
|
+
return false;
|
|
1180
|
+
if (candidate.kind === 'semantic_member'
|
|
1181
|
+
&& candidateIdentityTerms(candidate).some((term) => metricTermsMatch(term, requested)))
|
|
1182
|
+
return true;
|
|
1183
|
+
const normalized = normalizeEvidenceAnalyticalCapability(candidate);
|
|
1184
|
+
return normalized.status === 'complete'
|
|
1185
|
+
&& Boolean(normalized.capability?.dimensions.some((dimension) => metricTermsMatch(normalizeMetricPhrase(dimension.dimensionId), requested)));
|
|
1186
|
+
}
|
|
1187
|
+
function candidateProvesMetricTerm(candidate, requested) {
|
|
1188
|
+
if (candidate.compatibility === 'incompatible')
|
|
1189
|
+
return false;
|
|
1190
|
+
if (candidate.kind === 'certified_block') {
|
|
1191
|
+
return candidate.compatibility === 'compatible';
|
|
1192
|
+
}
|
|
1193
|
+
if (candidate.kind !== 'semantic_metric')
|
|
1194
|
+
return false;
|
|
1195
|
+
const stableIdentity = candidate.qualifiedId ?? candidate.id;
|
|
1196
|
+
if (!stableIdentity)
|
|
1197
|
+
return false;
|
|
1198
|
+
return [candidate.name, ...(candidate.aliases ?? [])]
|
|
1199
|
+
.map(normalizeMetricPhrase)
|
|
1200
|
+
.some((term) => metricTermsMatch(term, requested));
|
|
1201
|
+
}
|
|
1202
|
+
function candidateIsDeclaredDimensionAlternative(candidate, missingDimensions) {
|
|
1203
|
+
const facts = candidate.compatibilityFacts?.map(normalizeMetricPhrase) ?? [];
|
|
1204
|
+
return missingDimensions.some((requested) => facts.includes(`alternative for ${requested}`)
|
|
1205
|
+
|| facts.includes(`dimension alternative for ${requested}`));
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* A missing optional parsed-intent projection must not erase positive metric
|
|
1209
|
+
* evidence already retrieved for the question. Only complete, compatible,
|
|
1210
|
+
* qualified semantic metrics with an exact/explicit match reason can bypass
|
|
1211
|
+
* the bare-ranking clarification; mere catalog presence is insufficient.
|
|
1212
|
+
*/
|
|
1213
|
+
function hasStrongQualifiedMetricEvidence(candidates) {
|
|
1214
|
+
return candidates.some((candidate) => {
|
|
1215
|
+
if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
|
|
1216
|
+
return false;
|
|
1217
|
+
if (!candidate.qualifiedId || !normalizeEvidenceAnalyticalCapability(candidate).capability)
|
|
1218
|
+
return false;
|
|
1219
|
+
if (candidate.exactMatch)
|
|
1220
|
+
return true;
|
|
1221
|
+
return candidate.matchReasons.some((reason) => {
|
|
1222
|
+
const normalized = normalizeMetricPhrase(reason);
|
|
1223
|
+
return /\b(?:exact|explicit)\b/.test(normalized)
|
|
1224
|
+
&& /\b(?:metric|measure|meaning|name|alias)\b/.test(normalized);
|
|
1225
|
+
});
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
function hasQuestionQualifiedMetricEvidence(question, evidence, candidates) {
|
|
1229
|
+
const questionTokens = new Set(substantiveLexicalTokens(question));
|
|
1230
|
+
const dimensionTokens = new Set(substantiveLexicalTokens((evidence.parsedIntent?.dimensions ?? []).join(' ')));
|
|
1231
|
+
return candidates.some((candidate) => {
|
|
1232
|
+
if (candidate.kind !== 'semantic_metric' || candidate.compatibility === 'incompatible')
|
|
1233
|
+
return false;
|
|
1234
|
+
const normalized = normalizeEvidenceAnalyticalCapability(candidate);
|
|
1235
|
+
if (normalized.status !== 'complete' || !candidate.qualifiedId)
|
|
1236
|
+
return false;
|
|
1237
|
+
return metricLexicalVariants(candidate).some((variant) => {
|
|
1238
|
+
const metricTokens = [...new Set(substantiveLexicalTokens(variant)
|
|
1239
|
+
.filter((token) => !dimensionTokens.has(token)))];
|
|
1240
|
+
if (metricTokens.length === 0)
|
|
1241
|
+
return false;
|
|
1242
|
+
const matched = metricTokens.filter((token) => questionTokens.has(token)).length;
|
|
1243
|
+
return metricTokens.length === 1
|
|
1244
|
+
? matched === 1
|
|
1245
|
+
: matched >= 2 && matched / metricTokens.length >= 0.5;
|
|
1246
|
+
});
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Compare both authored identities and their canonical local names. Generated
|
|
1251
|
+
* semantic-model namespaces remain available as authored evidence, but cannot
|
|
1252
|
+
* dilute the leaf metric phrase that users naturally ask for.
|
|
1253
|
+
*/
|
|
1254
|
+
function metricLexicalVariants(candidate) {
|
|
1255
|
+
const authored = [
|
|
1256
|
+
candidate.name,
|
|
1257
|
+
...(candidate.aliases ?? []),
|
|
1258
|
+
...(candidate.qualifiedId ? [candidate.qualifiedId] : []),
|
|
1259
|
+
].filter(Boolean);
|
|
1260
|
+
return [...new Set(authored.flatMap((value) => {
|
|
1261
|
+
const local = value.split(/[.:/]/).filter(Boolean).at(-1);
|
|
1262
|
+
return local && local !== value ? [value, local] : [value];
|
|
1263
|
+
}))];
|
|
1264
|
+
}
|
|
1265
|
+
const NON_SUBSTANTIVE_RANKING_TOKENS = new Set([
|
|
1266
|
+
'a', 'an', 'and', 'are', 'at', 'best', 'bottom', 'by', 'count', 'for',
|
|
1267
|
+
'from', 'give', 'has', 'have', 'highest', 'in', 'is', 'least', 'list',
|
|
1268
|
+
'lowest', 'me', 'measure', 'metric', 'most', 'number', 'of', 'on', 'or',
|
|
1269
|
+
'per', 'rank', 'ranked', 'ranking', 'show', 'that', 'the', 'this', 'to',
|
|
1270
|
+
'top', 'total', 'value', 'what', 'which', 'who', 'with', 'without', 'worst',
|
|
1271
|
+
'amount',
|
|
1272
|
+
]);
|
|
1273
|
+
function substantiveLexicalTokens(value) {
|
|
1274
|
+
return normalizeMetricPhrase(value)
|
|
1275
|
+
.split(' ')
|
|
1276
|
+
.filter((token) => token.length >= 2 && !NON_SUBSTANTIVE_RANKING_TOKENS.has(token));
|
|
1277
|
+
}
|
|
1278
|
+
function renderCandidateChoice(candidate) {
|
|
1279
|
+
const identity = candidate.qualifiedId ?? candidate.id;
|
|
1280
|
+
const stableName = identity.split(/[.:]/).at(-1) ?? candidate.name;
|
|
1281
|
+
// Never paste a raw semantic-layer record into the question a person reads.
|
|
1282
|
+
const description = humanizeCandidateDefinition(candidate.definition);
|
|
1283
|
+
const grain = candidate.primaryEntity?.trim();
|
|
1284
|
+
const kind = candidateKindLabel(candidate.kind);
|
|
1285
|
+
const detail = [description, grain ? `grain: ${grain}` : ''].filter(Boolean).join('; ');
|
|
1286
|
+
return detail
|
|
1287
|
+
? `${candidate.name} — ${kind}, ${detail}`
|
|
1288
|
+
: `${candidate.name} — ${kind} (${stableName})`;
|
|
1289
|
+
}
|
|
719
1290
|
/**
|
|
720
1291
|
* AGT-017 / AGT-018 / PERF-002 — provider failure must not send an otherwise
|
|
721
1292
|
* unambiguous semantic question back through the legacy answer cascade. A dbt
|
|
@@ -773,7 +1344,13 @@ function hasMateriallyRelatedCompetitor(exact, candidates) {
|
|
|
773
1344
|
const floor = Math.max(0.55, exact.relevanceScore - 0.2);
|
|
774
1345
|
return candidates.some((candidate) => candidate.id !== exact.id
|
|
775
1346
|
&& candidate.compatibility !== "incompatible"
|
|
776
|
-
&& candidate.relevanceScore >= floor
|
|
1347
|
+
&& candidate.relevanceScore >= floor
|
|
1348
|
+
// A dimension/member card can help bind the selected metric's tuple, but
|
|
1349
|
+
// it is not a competing business metric meaning. Treating it as one sends
|
|
1350
|
+
// an exact metric plus its own dimension candidates back to the resolver.
|
|
1351
|
+
&& (exact.kind !== 'semantic_metric'
|
|
1352
|
+
|| candidate.kind === 'semantic_metric'
|
|
1353
|
+
|| candidate.kind === 'certified_block'));
|
|
777
1354
|
}
|
|
778
1355
|
/**
|
|
779
1356
|
* Skip the meaning-model call only when host-owned fit checks have already
|
|
@@ -806,6 +1383,13 @@ function dominantCompatibleGovernedCandidate(candidates) {
|
|
|
806
1383
|
const hasExecutableCompetitor = compatible.some((candidate) => candidate.id !== best.id && candidate.relevanceScore >= competitorFloor);
|
|
807
1384
|
return hasExecutableCompetitor ? undefined : best;
|
|
808
1385
|
}
|
|
1386
|
+
function authoritativeExactCertifiedExample(candidates) {
|
|
1387
|
+
const exact = candidates.filter((candidate) => candidate.kind === 'certified_block'
|
|
1388
|
+
&& candidate.exactMatch
|
|
1389
|
+
&& candidate.compatibility === 'compatible'
|
|
1390
|
+
&& candidate.analyticalFitClass === 'exact');
|
|
1391
|
+
return exact.length === 1 ? exact[0] : undefined;
|
|
1392
|
+
}
|
|
809
1393
|
function shouldDeferCompositionalFollowUpToExecutor(base, candidates) {
|
|
810
1394
|
if (!base.followsUp)
|
|
811
1395
|
return false;
|
|
@@ -878,6 +1462,10 @@ export function createHybridRouter(options = {}) {
|
|
|
878
1462
|
if (base.action === "converse" || base.action === "compose_app") {
|
|
879
1463
|
return { ...base, source: base.source ?? "heuristic" };
|
|
880
1464
|
}
|
|
1465
|
+
const initialDiscoveryRoute = discoveryRouteBeforeRetrieval(request, base);
|
|
1466
|
+
if (request.runBudget && !request.runBudget.mayStartDiscovery(initialDiscoveryRoute)) {
|
|
1467
|
+
return softBoundaryDecision(request, base, initialDiscoveryRoute);
|
|
1468
|
+
}
|
|
881
1469
|
let evidence;
|
|
882
1470
|
if (options.getEvidence) {
|
|
883
1471
|
try {
|
|
@@ -890,6 +1478,16 @@ export function createHybridRouter(options = {}) {
|
|
|
890
1478
|
}
|
|
891
1479
|
}
|
|
892
1480
|
if (evidence) {
|
|
1481
|
+
evidence = {
|
|
1482
|
+
...evidence,
|
|
1483
|
+
candidates: canonicalizeMetricMeasureCandidates(evidence.candidates),
|
|
1484
|
+
...(evidence.clarificationCandidates
|
|
1485
|
+
? { clarificationCandidates: canonicalizeMetricMeasureCandidates([
|
|
1486
|
+
...evidence.candidates,
|
|
1487
|
+
...evidence.clarificationCandidates,
|
|
1488
|
+
]).filter((candidate) => evidence.clarificationCandidates.some((item) => item.id === candidate.id)) }
|
|
1489
|
+
: {}),
|
|
1490
|
+
};
|
|
893
1491
|
let candidates = buildMeaningEvidencePackage(evidence, options.maxMeaningCandidates ?? 12);
|
|
894
1492
|
// A structured clarification selection is authoritative identity input,
|
|
895
1493
|
// not a new fuzzy-search phrase. Keep it in the bounded package even if
|
|
@@ -902,6 +1500,16 @@ export function createHybridRouter(options = {}) {
|
|
|
902
1500
|
.slice(0, options.maxMeaningCandidates ?? 12);
|
|
903
1501
|
}
|
|
904
1502
|
if (candidates.length > 0) {
|
|
1503
|
+
// Clarification is local and never provider-bound, so it can inspect the
|
|
1504
|
+
// complete already-retrieved set. Keep the smaller package below for
|
|
1505
|
+
// any later meaning call.
|
|
1506
|
+
const clarificationCandidates = [
|
|
1507
|
+
...evidence.candidates,
|
|
1508
|
+
...(evidence.clarificationCandidates ?? []),
|
|
1509
|
+
].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;
|
|
905
1513
|
const explicit = selectedEvidence ?? findExplicitEvidenceReference(request.question, candidates);
|
|
906
1514
|
if (explicit && explicit.compatibility !== "incompatible") {
|
|
907
1515
|
const decision = routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, explicit, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
@@ -913,6 +1521,10 @@ export function createHybridRouter(options = {}) {
|
|
|
913
1521
|
if (multiMetricPrimary) {
|
|
914
1522
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, multiMetricPrimary, candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
915
1523
|
}
|
|
1524
|
+
const authoredExample = authoritativeExactCertifiedExample(candidates);
|
|
1525
|
+
if (authoredExample) {
|
|
1526
|
+
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, authoredExample, candidates), 'heuristic', request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
1527
|
+
}
|
|
916
1528
|
const exactCompatible = candidates.filter((candidate) => candidate.exactMatch && candidate.compatibility !== "incompatible");
|
|
917
1529
|
if (exactCompatible.length === 1 && !hasMateriallyRelatedCompetitor(exactCompatible[0], candidates)) {
|
|
918
1530
|
return routeDecisionForResolution(base, evidence, candidates, directResolution(request, evidence, exactCompatible[0], candidates), "heuristic", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
@@ -929,12 +1541,29 @@ export function createHybridRouter(options = {}) {
|
|
|
929
1541
|
if (cached && (options.cacheTtlMs === undefined || now() - cached.at < cacheTtlMs)) {
|
|
930
1542
|
return { ...cached.decision, source: "cache" };
|
|
931
1543
|
}
|
|
1544
|
+
// Distinguish "the resolver could not run" from "the resolver ran and
|
|
1545
|
+
// froze nothing". Only the first is a reason to refuse to interpret:
|
|
1546
|
+
// with the semantic judgment unavailable, lexical rank alone must not
|
|
1547
|
+
// settle two genuinely different metrics (AGT-017). When the resolver
|
|
1548
|
+
// did run, committing to the best governed reading is the whole point.
|
|
1549
|
+
let meaningResolverReachable = true;
|
|
932
1550
|
try {
|
|
1551
|
+
if (request.runBudget && !request.runBudget.mayStartDiscovery('clarify')) {
|
|
1552
|
+
return softBoundaryDecision(request, base, 'clarify');
|
|
1553
|
+
}
|
|
933
1554
|
const resolution = options.resolveMeaning
|
|
934
1555
|
? await options.resolveMeaning({
|
|
935
1556
|
question: request.question,
|
|
936
1557
|
history: effectiveConversationHistory(request),
|
|
937
|
-
evidence
|
|
1558
|
+
// The resolver/provider receives the same bounded evidence
|
|
1559
|
+
// package as its candidate argument. Supplemental qualified
|
|
1560
|
+
// cards are a host-only clarification aid and must not leak
|
|
1561
|
+
// through this richer carrier.
|
|
1562
|
+
evidence: {
|
|
1563
|
+
...evidence,
|
|
1564
|
+
candidates,
|
|
1565
|
+
clarificationCandidates: undefined,
|
|
1566
|
+
},
|
|
938
1567
|
candidates,
|
|
939
1568
|
signal: request.signal ?? options.signal,
|
|
940
1569
|
})
|
|
@@ -953,21 +1582,24 @@ export function createHybridRouter(options = {}) {
|
|
|
953
1582
|
const invalidResolution = {
|
|
954
1583
|
interpretedQuestion: request.question,
|
|
955
1584
|
questionType: questionTypeFromText(request.question),
|
|
956
|
-
selectedConceptIds:
|
|
957
|
-
|
|
1585
|
+
selectedConceptIds: resolution.selectedConceptIds,
|
|
1586
|
+
recommendedExecutionId: resolution.recommendedExecutionId,
|
|
1587
|
+
queryIntent: resolution.queryIntent,
|
|
958
1588
|
rejectedCandidates: [],
|
|
959
1589
|
confidence: "low",
|
|
960
1590
|
missingInformation: [validated.reason],
|
|
961
1591
|
recommendedRoute: "clarify",
|
|
962
|
-
|
|
1592
|
+
compatibilityOutcome: 'modeling_gap',
|
|
1593
|
+
compatibilityFailures: [{
|
|
1594
|
+
code: 'INVALID_EVIDENCE_REFERENCE',
|
|
1595
|
+
field: 'meaningResolution',
|
|
1596
|
+
message: validated.reason,
|
|
1597
|
+
candidateIds: [],
|
|
1598
|
+
}],
|
|
963
1599
|
};
|
|
964
1600
|
const invalidDecision = routeDecisionForResolution(base, evidence, candidates, invalidResolution, "llm", request.question, options.resolvedPlanMode ?? 'authoritative');
|
|
965
1601
|
return remember(key, {
|
|
966
1602
|
...invalidDecision,
|
|
967
|
-
action: 'answer',
|
|
968
|
-
requiresClarification: false,
|
|
969
|
-
clarificationOptions: undefined,
|
|
970
|
-
clarifyingQuestion: undefined,
|
|
971
1603
|
meaningResolutionErrorCode: 'invalid_evidence_reference',
|
|
972
1604
|
});
|
|
973
1605
|
}
|
|
@@ -976,8 +1608,9 @@ export function createHybridRouter(options = {}) {
|
|
|
976
1608
|
rethrowCancellation(error, request.signal, options.signal);
|
|
977
1609
|
// A resolver transport/parse failure falls back without losing the
|
|
978
1610
|
// retrieval signal or permitting a general-knowledge misroute.
|
|
1611
|
+
meaningResolverReachable = false;
|
|
979
1612
|
}
|
|
980
|
-
return routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative');
|
|
1613
|
+
return routeWithoutMeaningModel(request, base, evidence, candidates, options.resolvedPlanMode ?? 'authoritative', meaningResolverReachable);
|
|
981
1614
|
}
|
|
982
1615
|
}
|
|
983
1616
|
// Legacy/no-evidence path. A confident analytical heuristic stays offline;
|
|
@@ -987,6 +1620,9 @@ export function createHybridRouter(options = {}) {
|
|
|
987
1620
|
return { ...base, source: base.source ?? "heuristic" };
|
|
988
1621
|
}
|
|
989
1622
|
let catalogContext;
|
|
1623
|
+
if (request.runBudget && !request.runBudget.mayStartDiscovery('clarify')) {
|
|
1624
|
+
return softBoundaryDecision(request, base, 'clarify');
|
|
1625
|
+
}
|
|
990
1626
|
try {
|
|
991
1627
|
catalogContext = options.getCatalogContext ? await options.getCatalogContext(request) : undefined;
|
|
992
1628
|
}
|
|
@@ -1017,5 +1653,30 @@ export function createHybridRouter(options = {}) {
|
|
|
1017
1653
|
},
|
|
1018
1654
|
};
|
|
1019
1655
|
}
|
|
1656
|
+
function discoveryRouteBeforeRetrieval(request, base) {
|
|
1657
|
+
if (request.runBudget?.mode === 'research' || request.requestedMode === 'research')
|
|
1658
|
+
return 'research';
|
|
1659
|
+
if (base.action === 'clarify' || base.requiresClarification || request.signals?.missingContext?.length)
|
|
1660
|
+
return 'clarify';
|
|
1661
|
+
if (request.intent === 'exact_certified_lookup'
|
|
1662
|
+
|| (request.signals?.certifiedScore ?? 0) >= 0.5
|
|
1663
|
+
|| (request.signals?.metricScore ?? 0) >= 0.5)
|
|
1664
|
+
return 'semantic_answer';
|
|
1665
|
+
return 'generated_answer';
|
|
1666
|
+
}
|
|
1667
|
+
function softBoundaryDecision(request, base, route) {
|
|
1668
|
+
const seconds = Math.round((request.runBudget?.softTargetMs(route) ?? 15_000) / 1_000);
|
|
1669
|
+
return {
|
|
1670
|
+
...base,
|
|
1671
|
+
action: 'clarify',
|
|
1672
|
+
confidence: 1,
|
|
1673
|
+
source: 'heuristic',
|
|
1674
|
+
requiresClarification: true,
|
|
1675
|
+
reason: `The ${seconds}-second discovery target elapsed before a plan was frozen, so DQL did not start another retrieval or provider branch.`,
|
|
1676
|
+
clarifyingQuestion: request.runBudget?.mode === 'research'
|
|
1677
|
+
? 'Research has stopped starting new branches. Would you like to narrow the question and retry?'
|
|
1678
|
+
: 'The discovery window ended before an exact plan was frozen. Which metric or grain should DQL use on retry?',
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1020
1681
|
export { intentForCategory, parseMeaningResolution };
|
|
1021
1682
|
//# sourceMappingURL=router.js.map
|