@duckcodeailabs/dql-agent 1.7.1 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-run-engine.d.ts +2 -0
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +67 -19
- package/dist/agent-run-engine.js.map +1 -1
- package/dist/agent-run-gates.d.ts.map +1 -1
- package/dist/agent-run-gates.js +14 -50
- package/dist/agent-run-gates.js.map +1 -1
- package/dist/agent-run-planner.d.ts.map +1 -1
- package/dist/agent-run-planner.js +14 -4
- package/dist/agent-run-planner.js.map +1 -1
- package/dist/agentic/toolset.js +32 -2
- package/dist/agentic/toolset.js.map +1 -1
- package/dist/answer-loop.d.ts +24 -0
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +387 -55
- package/dist/answer-loop.js.map +1 -1
- package/dist/answer-shape.js +14 -1
- package/dist/answer-shape.js.map +1 -1
- package/dist/cascade/budgets.d.ts.map +1 -1
- package/dist/cascade/budgets.js +5 -2
- package/dist/cascade/budgets.js.map +1 -1
- package/dist/conversation/session-store.d.ts +3 -0
- package/dist/conversation/session-store.d.ts.map +1 -1
- package/dist/conversation/session-store.js +8 -3
- package/dist/conversation/session-store.js.map +1 -1
- package/dist/conversation/snapshot.d.ts +2 -0
- package/dist/conversation/snapshot.d.ts.map +1 -1
- package/dist/conversation/snapshot.js +1 -0
- package/dist/conversation/snapshot.js.map +1 -1
- package/dist/conversation/working-state.d.ts +3 -0
- package/dist/conversation/working-state.d.ts.map +1 -1
- package/dist/conversation/working-state.js +26 -0
- package/dist/conversation/working-state.js.map +1 -1
- package/dist/domain-context.d.ts +14 -0
- package/dist/domain-context.d.ts.map +1 -1
- package/dist/domain-context.js +10 -0
- package/dist/domain-context.js.map +1 -1
- package/dist/embeddings/provider.d.ts +3 -1
- package/dist/embeddings/provider.d.ts.map +1 -1
- package/dist/embeddings/provider.js +19 -8
- package/dist/embeddings/provider.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +33 -4
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts +15 -0
- package/dist/intent-controller.d.ts.map +1 -1
- package/dist/intent-controller.js +4 -1
- package/dist/intent-controller.js.map +1 -1
- package/dist/meaning-resolution.d.ts +110 -0
- package/dist/meaning-resolution.d.ts.map +1 -0
- package/dist/meaning-resolution.js +155 -0
- package/dist/meaning-resolution.js.map +1 -0
- package/dist/metadata/analysis-planner.d.ts +15 -1
- package/dist/metadata/analysis-planner.d.ts.map +1 -1
- package/dist/metadata/analysis-planner.js +169 -50
- 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 +51 -5
- package/dist/metadata/block-fit.js.map +1 -1
- package/dist/metadata/catalog.d.ts +62 -3
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +911 -98
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/grain-gate.js +27 -9
- package/dist/metadata/grain-gate.js.map +1 -1
- package/dist/metadata/meaning-evidence.d.ts +70 -0
- package/dist/metadata/meaning-evidence.d.ts.map +1 -0
- package/dist/metadata/meaning-evidence.js +333 -0
- package/dist/metadata/meaning-evidence.js.map +1 -0
- package/dist/metadata/metric-match.d.ts.map +1 -1
- package/dist/metadata/metric-match.js +5 -2
- package/dist/metadata/metric-match.js.map +1 -1
- package/dist/router.d.ts +16 -4
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +409 -21
- package/dist/router.js.map +1 -1
- package/dist/semantic-bridge/compose.d.ts +12 -0
- package/dist/semantic-bridge/compose.d.ts.map +1 -1
- package/dist/semantic-bridge/compose.js +177 -14
- package/dist/semantic-bridge/compose.js.map +1 -1
- package/dist/semantic-bridge/member-select.d.ts.map +1 -1
- package/dist/semantic-bridge/member-select.js +59 -10
- package/dist/semantic-bridge/member-select.js.map +1 -1
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +35 -33
- package/dist/skills/loader.js.map +1 -1
- package/dist/synthesize.d.ts +2 -0
- package/dist/synthesize.d.ts.map +1 -1
- package/dist/synthesize.js +84 -13
- package/dist/synthesize.js.map +1 -1
- package/package.json +4 -4
package/dist/answer-loop.js
CHANGED
|
@@ -29,12 +29,13 @@ import { evaluateDbtFirstGeneratedSql } from './metadata/dbt-first-safety.js';
|
|
|
29
29
|
import { planAnalyticalPath } from './metadata/analytical-policy.js';
|
|
30
30
|
import { planCertifiedAdaptation } from './metadata/block-adapt.js';
|
|
31
31
|
import { compactSqlSnippet, extractSimpleSelectShape, selectExpressionOutputName, } from './metadata/sql-shape.js';
|
|
32
|
-
import { composeSemanticQueryForQuestion, composeSemanticQueryFromMembers } from './semantic-bridge/compose.js';
|
|
32
|
+
import { composeSemanticQueryForQuestion, composeSemanticQueryFromMembers, } from './semantic-bridge/compose.js';
|
|
33
33
|
import { runAgenticToolLoop } from './agentic/tool-loop.js';
|
|
34
34
|
import { buildSemanticStageTools } from './agentic/toolset.js';
|
|
35
35
|
import { deriveAgenticTrust } from './agentic/answer-contract.js';
|
|
36
36
|
import { selectSemanticMembersViaLlm } from './semantic-bridge/member-select.js';
|
|
37
37
|
import { normalizeValueIndexText } from './grounding/value-index.js';
|
|
38
|
+
import { questionTypeFromText } from './meaning-resolution.js';
|
|
38
39
|
import { cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, } from './cascade/cascade.js';
|
|
39
40
|
import { shouldClarifyBeforeGeneration } from './cascade/triage.js';
|
|
40
41
|
import { stampTrustLabel } from './trust/stamp.js';
|
|
@@ -45,18 +46,105 @@ import { QUICK_PROMPT_CONTEXT_BUDGET, canUseLaneRepair, cascadeBudgetTrace, crea
|
|
|
45
46
|
* it — a generic, project-agnostic replacement for hard-coded value→dimension maps.
|
|
46
47
|
*/
|
|
47
48
|
function resolveFilterValueColumns(value, schemaContext) {
|
|
49
|
+
return resolveAgentFilterValueBindings(value, schemaContext).map((binding) => binding.column);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Resolve a user-provided member phrase only against bounded values that were
|
|
53
|
+
* retrieved for eligible schema fields. This is deliberately separate from
|
|
54
|
+
* metadata/object search: the provider never chooses the canonical row value.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveAgentFilterValueBindings(value, schemaContext) {
|
|
48
57
|
const needle = normalizeValueIndexText(value);
|
|
49
58
|
if (!needle)
|
|
50
59
|
return [];
|
|
51
|
-
const
|
|
60
|
+
const candidates = [];
|
|
52
61
|
for (const table of schemaContext) {
|
|
53
62
|
for (const column of table.columns) {
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
for (const sample of column.sampleValues ?? []) {
|
|
64
|
+
const normalizedSample = normalizeValueIndexText(sample);
|
|
65
|
+
if (!normalizedSample)
|
|
66
|
+
continue;
|
|
67
|
+
if (normalizedSample === needle) {
|
|
68
|
+
candidates.push({
|
|
69
|
+
column: column.name,
|
|
70
|
+
canonicalValue: sample,
|
|
71
|
+
match: sample === value ? 'exact' : 'normalized',
|
|
72
|
+
confidence: 1,
|
|
73
|
+
distance: 0,
|
|
74
|
+
});
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const needleTokens = needle.split(' ').filter(Boolean);
|
|
78
|
+
const sampleTokens = normalizedSample.split(' ').filter(Boolean);
|
|
79
|
+
// A stakeholder often refers to a result member by its distinctive first
|
|
80
|
+
// token ("Melissa") while the warehouse stores the display value as
|
|
81
|
+
// "Melissa Lopez". Treat a strict token subset as a high-confidence fuzzy
|
|
82
|
+
// candidate, but let the runner-up ambiguity guard below reject it when
|
|
83
|
+
// more than one sampled value shares that token.
|
|
84
|
+
if (needleTokens.length < sampleTokens.length
|
|
85
|
+
&& needleTokens.every((token) => token.length >= 3 && sampleTokens.includes(token))) {
|
|
86
|
+
candidates.push({
|
|
87
|
+
column: column.name,
|
|
88
|
+
canonicalValue: sample,
|
|
89
|
+
match: 'fuzzy',
|
|
90
|
+
confidence: 0.97,
|
|
91
|
+
distance: sampleTokens.length - needleTokens.length,
|
|
92
|
+
});
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (needleTokens.length !== sampleTokens.length)
|
|
96
|
+
continue;
|
|
97
|
+
const distance = damerauLevenshteinDistance(needle, normalizedSample);
|
|
98
|
+
const similarity = 1 - (distance / Math.max(needle.length, normalizedSample.length, 1));
|
|
99
|
+
const singleToken = needleTokens.length === 1;
|
|
100
|
+
const threshold = singleToken ? 0.94 : 0.92;
|
|
101
|
+
const maxDistance = singleToken ? 1 : 2;
|
|
102
|
+
const hasExactToken = needleTokens.some((token) => sampleTokens.includes(token));
|
|
103
|
+
if (similarity < threshold || distance > maxDistance || (!singleToken && !hasExactToken))
|
|
104
|
+
continue;
|
|
105
|
+
candidates.push({
|
|
106
|
+
column: column.name,
|
|
107
|
+
canonicalValue: sample,
|
|
108
|
+
match: 'fuzzy',
|
|
109
|
+
confidence: Number(similarity.toFixed(4)),
|
|
110
|
+
distance,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
candidates.sort((a, b) => b.confidence - a.confidence || a.distance - b.distance || a.column.localeCompare(b.column));
|
|
116
|
+
const best = candidates[0];
|
|
117
|
+
if (!best)
|
|
118
|
+
return [];
|
|
119
|
+
const runnerUp = candidates.find((candidate) => (candidate.column !== best.column
|
|
120
|
+
|| normalizeValueIndexText(candidate.canonicalValue) !== normalizeValueIndexText(best.canonicalValue)));
|
|
121
|
+
if (best.match === 'fuzzy' && runnerUp && best.confidence - runnerUp.confidence < 0.08)
|
|
122
|
+
return [];
|
|
123
|
+
return candidates
|
|
124
|
+
.filter((candidate) => candidate.confidence === best.confidence
|
|
125
|
+
&& normalizeValueIndexText(candidate.canonicalValue) === normalizeValueIndexText(best.canonicalValue))
|
|
126
|
+
.map(({ distance: _distance, ...binding }) => binding);
|
|
127
|
+
}
|
|
128
|
+
function damerauLevenshteinDistance(left, right) {
|
|
129
|
+
const rows = left.length + 1;
|
|
130
|
+
const columns = right.length + 1;
|
|
131
|
+
const matrix = Array.from({ length: rows }, () => Array(columns).fill(0));
|
|
132
|
+
for (let row = 0; row < rows; row += 1)
|
|
133
|
+
matrix[row][0] = row;
|
|
134
|
+
for (let column = 0; column < columns; column += 1)
|
|
135
|
+
matrix[0][column] = column;
|
|
136
|
+
for (let row = 1; row < rows; row += 1) {
|
|
137
|
+
for (let column = 1; column < columns; column += 1) {
|
|
138
|
+
const cost = left[row - 1] === right[column - 1] ? 0 : 1;
|
|
139
|
+
matrix[row][column] = Math.min(matrix[row - 1][column] + 1, matrix[row][column - 1] + 1, matrix[row - 1][column - 1] + cost);
|
|
140
|
+
if (row > 1 && column > 1
|
|
141
|
+
&& left[row - 1] === right[column - 2]
|
|
142
|
+
&& left[row - 2] === right[column - 1]) {
|
|
143
|
+
matrix[row][column] = Math.min(matrix[row][column], matrix[row - 2][column - 2] + cost);
|
|
56
144
|
}
|
|
57
145
|
}
|
|
58
146
|
}
|
|
59
|
-
return
|
|
147
|
+
return matrix[left.length][right.length];
|
|
60
148
|
}
|
|
61
149
|
const CERTIFIED_HIT_THRESHOLD = 0.18;
|
|
62
150
|
const HARD_NEGATIVE_RATIO = 0.5;
|
|
@@ -315,7 +403,7 @@ async function runAnswerLoop(input) {
|
|
|
315
403
|
: [];
|
|
316
404
|
const hasExplicitDomainScope = Boolean(input.domain || input.domainContext?.activeDomain);
|
|
317
405
|
const questionDomainScope = hasExplicitDomainScope ? [] : directQuestionDomains;
|
|
318
|
-
const
|
|
406
|
+
const scopedContextPack = questionDomainScope.length > 0
|
|
319
407
|
? scopeContextPackToQuestionDomains(input.contextPack, questionDomainScope, input.manifest)
|
|
320
408
|
: input.contextPack;
|
|
321
409
|
// Select the RELEVANT skills (not all) for this question; keep pinned project
|
|
@@ -356,11 +444,18 @@ async function runAnswerLoop(input) {
|
|
|
356
444
|
const semanticHits = kg.search({ query: question, ...searchScope, kinds: SEMANTIC_KINDS, limit: 12 });
|
|
357
445
|
const manifestHits = kg.search({ query: question, ...searchScope, kinds: MANIFEST_KINDS, limit: 12 });
|
|
358
446
|
const considered = mergeHits(artifactHits, semanticHits, manifestHits, kg.search({ query: question, domain, limit: 10 })).slice(0, 30);
|
|
359
|
-
const schemaContext = schemaContextWithAllowedSqlContext(schemaContextWithinQuestionScope(input.schemaContext ?? [], input.contextPack,
|
|
447
|
+
const schemaContext = schemaContextWithAllowedSqlContext(schemaContextWithinQuestionScope(input.schemaContext ?? [], input.contextPack, scopedContextPack), scopedContextPack);
|
|
360
448
|
const catalogRoute = input.contextPack?.routeDecision;
|
|
361
449
|
const questionPlan = input.contextPack?.questionPlan?.requestedShape
|
|
362
450
|
? input.contextPack.questionPlan
|
|
363
451
|
: buildAnalysisQuestionPlan(question, input.followUp);
|
|
452
|
+
// Retrieval may surface a high-trust block because its source tables and
|
|
453
|
+
// vocabulary overlap the question even when its output contract does not.
|
|
454
|
+
// Keep such candidates in the audit trail, but do not put their SQL or a stale
|
|
455
|
+
// "exact certified" route into the generation prompt. The model should decide
|
|
456
|
+
// from compatible certified evidence, semantic members, and dbt/runtime
|
|
457
|
+
// columns—not copy a customer-grain worked example into a product-grain ask.
|
|
458
|
+
const promptContextPack = contextPackForRequestedShape(scopedContextPack, question, questionPlan, kg);
|
|
364
459
|
const repairBudgetState = createCascadeBudgetState(input.cascadeBudgetModel);
|
|
365
460
|
const fallbackIntent = classifyAgentIntent({
|
|
366
461
|
question,
|
|
@@ -381,9 +476,12 @@ async function runAnswerLoop(input) {
|
|
|
381
476
|
if (node && !semanticMetricNodes.some((candidate) => candidate.nodeId === node.nodeId))
|
|
382
477
|
semanticMetricNodes.push(node);
|
|
383
478
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
479
|
+
const preferredSemanticMetric = resolvePreferredSemanticMetric([input.preferredExecutionId, ...(input.preferredEvidenceIds ?? [])], semanticMetricNodes, kg);
|
|
480
|
+
let semanticMetricMatch = preferredSemanticMetric
|
|
481
|
+
? { metric: preferredSemanticMetric, score: 1, basis: 'name' }
|
|
482
|
+
: await matchSemanticMetric(semanticQuestion, semanticMetricNodes, {
|
|
483
|
+
measureTerms: [...questionPlan.requestedShape.measures, ...questionPlan.metricTerms],
|
|
484
|
+
}).catch(() => null);
|
|
387
485
|
// Stage 1: certified artifact match. Blocks can be executed; dashboards,
|
|
388
486
|
// Apps, and notebooks are returned as governed citations/navigation targets.
|
|
389
487
|
const drilldownCertifiedHit = input.followUp?.kind === 'drilldown'
|
|
@@ -400,7 +498,12 @@ async function runAnswerLoop(input) {
|
|
|
400
498
|
const catalogCertifiedHit = shouldTryCertifiedRoute
|
|
401
499
|
? certifiedHitFromContextPack(input.contextPack, kg)
|
|
402
500
|
: null;
|
|
403
|
-
|
|
501
|
+
// Catalog route scores are retrieval evidence, not permission to execute a
|
|
502
|
+
// block. Always enforce the output/grain/filter contract—even when no semantic
|
|
503
|
+
// metric happened to match. Previously this guard only ran when Lane 2 had a
|
|
504
|
+
// metric, so a high-scoring `top_beverage_customers` catalog hit could answer a
|
|
505
|
+
// product-type → product-name flow request with customer rows.
|
|
506
|
+
const unsafeCatalogCertifiedHit = catalogCertifiedHit?.node.kind === 'block'
|
|
404
507
|
&& !hasCertifiedNodeFit(question, questionPlan, catalogCertifiedHit.node)
|
|
405
508
|
? null
|
|
406
509
|
: catalogCertifiedHit;
|
|
@@ -416,6 +519,7 @@ async function runAnswerLoop(input) {
|
|
|
416
519
|
}) : null;
|
|
417
520
|
let artifactHit = drilldownCertifiedHit ?? unsafeCatalogCertifiedHit
|
|
418
521
|
?? (catalogCertifiedHit ? null : fallbackCertifiedHit);
|
|
522
|
+
let certifiedExecutionFallback;
|
|
419
523
|
// Certified remains first when it actually covers the question. If the
|
|
420
524
|
// retrieved block does not fit but a governed semantic metric does, never
|
|
421
525
|
// let the broad catalog match pre-empt Lane 2.
|
|
@@ -449,7 +553,7 @@ async function runAnswerLoop(input) {
|
|
|
449
553
|
// explicitly names the object in a definition request ("what is Revenue
|
|
450
554
|
// Health?"). A broad lexical match must never turn an analytical question
|
|
451
555
|
// into a no-data Certified answer merely because it starts with "what is".
|
|
452
|
-
return
|
|
556
|
+
return isPureBusinessDefinitionQuestion(question)
|
|
453
557
|
&& objectNameInQuestion(question, artifactHit.node);
|
|
454
558
|
})();
|
|
455
559
|
if (artifactHit && businessContextTerminal) {
|
|
@@ -554,46 +658,60 @@ async function runAnswerLoop(input) {
|
|
|
554
658
|
suggestedViz: result?.chartConfig ? chartNameFromConfig(result.chartConfig) : undefined,
|
|
555
659
|
});
|
|
556
660
|
const dqlArtifact = buildCertifiedBlockDqlArtifact(artifactHit.node, result);
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
question,
|
|
580
|
-
artifact: artifactHit.node,
|
|
581
|
-
businessHits,
|
|
582
|
-
semanticHits,
|
|
583
|
-
manifestHits,
|
|
584
|
-
considered,
|
|
661
|
+
const recoverableCertifiedFailure = artifactHit.node.kind === 'block'
|
|
662
|
+
&& executionError !== undefined
|
|
663
|
+
&& isRetryableCertifiedExecutionError(executionError);
|
|
664
|
+
if (recoverableCertifiedFailure) {
|
|
665
|
+
// A certified artifact is trusted evidence, not an obligation to return a
|
|
666
|
+
// failed query. Preserve the failure for the trace, remove executable
|
|
667
|
+
// trust for this turn, and continue through semantic/generated lanes.
|
|
668
|
+
// Any fallback result remains review-required; repaired SQL never inherits
|
|
669
|
+
// the source block's certification.
|
|
670
|
+
certifiedExecutionFallback = { node: artifactHit.node, error: executionError };
|
|
671
|
+
artifactHit = null;
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
return {
|
|
675
|
+
kind: certifiedShapePassed ? 'certified' : 'uncertified',
|
|
676
|
+
sourceTier,
|
|
677
|
+
certification: certifiedShapePassed ? 'certified' : 'analyst_review_required',
|
|
678
|
+
reviewStatus: certifiedShapePassed ? 'certified' : 'analyst_review_required',
|
|
679
|
+
confidence: certifiedShapePassed ? 0.95 : 0.45,
|
|
680
|
+
text,
|
|
681
|
+
answer: text,
|
|
682
|
+
block: artifactHit.node.kind === 'block' ? artifactHit.node : undefined,
|
|
585
683
|
result,
|
|
586
684
|
executionError,
|
|
587
|
-
|
|
588
|
-
|
|
685
|
+
sql: result?.sql,
|
|
686
|
+
dqlArtifact,
|
|
687
|
+
trustLabel: certifiedShapePassed ? input.contextPack?.trustLabel ?? 'certified' : 'mixed',
|
|
688
|
+
sourceCertifiedBlock: artifactHit.node.kind === 'block' ? artifactHit.node.name : undefined,
|
|
689
|
+
contextPackId: input.contextPack?.id,
|
|
690
|
+
validationWarnings: resultShapeWarnings,
|
|
691
|
+
selectedEvidence: input.contextPack?.evidenceRoles?.slice(0, 12),
|
|
589
692
|
citations,
|
|
590
|
-
memoryContext: input.memoryContext
|
|
693
|
+
memoryContext: input.memoryContext,
|
|
591
694
|
analysisPlan,
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
695
|
+
evidence: buildCertifiedEvidence({
|
|
696
|
+
question,
|
|
697
|
+
artifact: artifactHit.node,
|
|
698
|
+
businessHits,
|
|
699
|
+
semanticHits,
|
|
700
|
+
manifestHits,
|
|
701
|
+
considered,
|
|
702
|
+
result,
|
|
703
|
+
executionError,
|
|
704
|
+
resultShapeWarnings,
|
|
705
|
+
executorWasAvailable: Boolean(input.executeCertifiedBlock),
|
|
706
|
+
citations,
|
|
707
|
+
memoryContext: input.memoryContext ?? [],
|
|
708
|
+
analysisPlan,
|
|
709
|
+
}),
|
|
710
|
+
contextPack: input.contextPack,
|
|
711
|
+
considered,
|
|
712
|
+
providerUsed: provider.name,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
597
715
|
}
|
|
598
716
|
// Spec 17, part C — SEMANTIC-METRIC MATCHING. Must run BEFORE the clarify
|
|
599
717
|
// short-circuit: FTS alone misses clear metric questions ("total revenue" never
|
|
@@ -699,7 +817,14 @@ async function runAnswerLoop(input) {
|
|
|
699
817
|
], 14);
|
|
700
818
|
const contextNodes = mergeNodes(followUpSourceBlock && input.followUp?.kind === 'drilldown' ? [followUpSourceBlock] : [], (contextHits.length > 0 ? contextHits : considered.slice(0, 6)).map((h) => h.node)).filter((node) => questionDomainScope.length === 0 || !node.domain || questionDomainScope.includes(node.domain));
|
|
701
819
|
const kgJoinPathHints = buildKgJoinPathHints(kg, contextNodes, questionPlan);
|
|
702
|
-
const contextBlocks = contextNodes.filter((
|
|
820
|
+
const contextBlocks = contextNodes.filter((node) => {
|
|
821
|
+
if (node.kind !== 'block')
|
|
822
|
+
return false;
|
|
823
|
+
if (node.status !== 'certified')
|
|
824
|
+
return true;
|
|
825
|
+
const fit = evaluateCertifiedBlockFit({ question, plan: questionPlan, block: node });
|
|
826
|
+
return fit.kind === 'exact' || fit.kind === 'trim_safe';
|
|
827
|
+
});
|
|
703
828
|
const contextBusiness = contextNodes.filter((n) => BUSINESS_CONTEXT_KINDS.includes(n.kind));
|
|
704
829
|
const contextOther = contextNodes.filter((n) => n.kind !== 'block' && !BUSINESS_CONTEXT_KINDS.includes(n.kind));
|
|
705
830
|
const promptBudget = promptContextBudgetForQuestion({
|
|
@@ -758,6 +883,7 @@ async function runAnswerLoop(input) {
|
|
|
758
883
|
questionPlan,
|
|
759
884
|
matchedMetric: semanticMetricMatch.metric,
|
|
760
885
|
filterValueColumns: (value) => resolveFilterValueColumns(value, schemaContext),
|
|
886
|
+
filterValueBindings: (value) => resolveAgentFilterValueBindings(value, schemaContext),
|
|
761
887
|
...(input.semanticDriver ? { driver: input.semanticDriver } : {}),
|
|
762
888
|
...(input.semanticTableMapping ? { tableMapping: input.semanticTableMapping } : {}),
|
|
763
889
|
});
|
|
@@ -1119,6 +1245,21 @@ async function runAnswerLoop(input) {
|
|
|
1119
1245
|
providerUsed: provider.name,
|
|
1120
1246
|
};
|
|
1121
1247
|
}
|
|
1248
|
+
if (parsed.sql) {
|
|
1249
|
+
const tightenedFlow = tightenSourceTargetFlowProjection(parsed.sql, question, questionPlan);
|
|
1250
|
+
if (tightenedFlow && tightenedFlow.sql !== parsed.sql) {
|
|
1251
|
+
parsed.sql = tightenedFlow.sql;
|
|
1252
|
+
parsed.outputs = tightenedFlow.outputs;
|
|
1253
|
+
// Any edit to compiler-owned SQL is no longer an exact governed compile.
|
|
1254
|
+
// Keep the corrected result review-required instead of overstating trust.
|
|
1255
|
+
governedMetricAnswer = false;
|
|
1256
|
+
// A deep candidate may already have executed the wider proposal. The
|
|
1257
|
+
// narrowed SQL is now authoritative and must be executed/validated anew.
|
|
1258
|
+
deepCandidateResult = undefined;
|
|
1259
|
+
deepCandidateExecutionError = undefined;
|
|
1260
|
+
deepCandidateNotes.push('Removed an unrelated grouping from the source-to-target flow projection.');
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1122
1263
|
// Shared grounding (spec 15): deterministically qualify any bare relation the
|
|
1123
1264
|
// model emitted to its real warehouse relation from the runtime schema BEFORE
|
|
1124
1265
|
// governance validation. Same resolver the build path uses — one grounding,
|
|
@@ -1548,6 +1689,9 @@ async function runAnswerLoop(input) {
|
|
|
1548
1689
|
suggestedViz: parsed.viz ?? 'table',
|
|
1549
1690
|
assumptions: [
|
|
1550
1691
|
'The SQL preview is uncertified until an analyst reviews and promotes the DQL artifact.',
|
|
1692
|
+
...(certifiedExecutionFallback
|
|
1693
|
+
? [`Certified block ${certifiedExecutionFallback.node.name} failed execution and was bypassed: ${certifiedExecutionFallback.error}`]
|
|
1694
|
+
: []),
|
|
1551
1695
|
...(repairNarrative ? [`Auto-corrected the query after an execution error: ${repairNarrative}`] : []),
|
|
1552
1696
|
...contextValidation.warnings,
|
|
1553
1697
|
...(executionError ? ['The preview execution error must be reviewed before reuse.'] : []),
|
|
@@ -1582,6 +1726,9 @@ async function runAnswerLoop(input) {
|
|
|
1582
1726
|
: undefined;
|
|
1583
1727
|
const validationWarnings = [
|
|
1584
1728
|
...(input.contextPack?.warnings ?? []),
|
|
1729
|
+
...(certifiedExecutionFallback
|
|
1730
|
+
? [`Certified block ${certifiedExecutionFallback.node.name} failed execution; this fallback result is review-required.`]
|
|
1731
|
+
: []),
|
|
1585
1732
|
...contextValidation.warnings,
|
|
1586
1733
|
...deepCandidateNotes,
|
|
1587
1734
|
...(resultShape?.warnings ?? []),
|
|
@@ -1769,6 +1916,10 @@ Rules:
|
|
|
1769
1916
|
an additional output dimension. An entity ranking filtered to a category
|
|
1770
1917
|
value must return one row per requested entity, not one row per
|
|
1771
1918
|
entity-category-member pair.
|
|
1919
|
+
For a Sankey/source-to-target flow, return exactly two categorical columns
|
|
1920
|
+
(the requested source first, then the requested target) plus one numeric
|
|
1921
|
+
weight. Do not add an unrelated grouping such as customer type, segment, or
|
|
1922
|
+
region unless the question explicitly asks for it.
|
|
1772
1923
|
4. In summary, state your QUERY PLAN first: the grain (one row per WHAT), the
|
|
1773
1924
|
measures and how they aggregate, the dimensions/filters, and the exact join
|
|
1774
1925
|
path + join keys between the grounded tables. Then make the SQL match that
|
|
@@ -1779,10 +1930,18 @@ Rules:
|
|
|
1779
1930
|
or use a grain-preserving value only when the owning entity remains in the
|
|
1780
1931
|
output. Never SUM the repeated parent value at child grain. If the question
|
|
1781
1932
|
requires allocating a parent value to children, ask for the allocation rule.
|
|
1933
|
+
For an entity-relative measure comparison such as "customers who paid less
|
|
1934
|
+
tax than Melissa", first aggregate the measure for every peer at the same
|
|
1935
|
+
entity grain, obtain the named reference entity's aggregate in a CTE or
|
|
1936
|
+
scalar subquery, then compare peers with the requested < or > predicate and
|
|
1937
|
+
exclude the reference entity. Do not filter the result down to the reference
|
|
1938
|
+
entity, compare unaggregated fact rows, or return one global aggregate.
|
|
1782
1939
|
5. Choose a visualization deliberately in the "viz" JSON field: use single_value
|
|
1783
1940
|
or kpi for one aggregate, line/area only for an ordered time series, bar for a
|
|
1784
1941
|
categorical comparison, grouped-bar for multiple measures by one category,
|
|
1785
|
-
|
|
1942
|
+
donut for a small part-to-whole result, scatter for two continuous measures,
|
|
1943
|
+
histogram for a distribution, funnel for ordered stages, waterfall for signed
|
|
1944
|
+
contributions, sankey for source-to-target flows, and table for detailed rows.
|
|
1786
1945
|
Do not default to bar. The runtime validates this preference against returned
|
|
1787
1946
|
rows before displaying it.
|
|
1788
1947
|
6. NEVER fabricate column names that are not present in the supplied schema,
|
|
@@ -1870,6 +2029,72 @@ function scopeContextPackToQuestionDomains(contextPack, domains, manifest) {
|
|
|
1870
2029
|
},
|
|
1871
2030
|
};
|
|
1872
2031
|
}
|
|
2032
|
+
/**
|
|
2033
|
+
* Build the prompt-facing evidence pack after enforcing the certified output
|
|
2034
|
+
* contract. Incompatible blocks remain in the returned answer's `considered`
|
|
2035
|
+
* evidence for inspection, but their prose/SQL cannot steer generation.
|
|
2036
|
+
*/
|
|
2037
|
+
function contextPackForRequestedShape(contextPack, question, questionPlan, kg) {
|
|
2038
|
+
if (!contextPack)
|
|
2039
|
+
return undefined;
|
|
2040
|
+
const incompatibleBlockKeys = new Set();
|
|
2041
|
+
for (const object of contextPack.objects) {
|
|
2042
|
+
if (object.objectType !== 'dql_block')
|
|
2043
|
+
continue;
|
|
2044
|
+
const blockName = object.name || object.objectKey.replace(/^dql:block:/, '');
|
|
2045
|
+
const node = kg.getNode(`block:${blockName}`);
|
|
2046
|
+
if (node?.kind === 'block') {
|
|
2047
|
+
const fit = evaluateCertifiedBlockFit({ question, plan: questionPlan, block: node });
|
|
2048
|
+
if (fit.kind === 'context_only' || fit.kind === 'not_applicable') {
|
|
2049
|
+
incompatibleBlockKeys.add(object.objectKey);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
if (incompatibleBlockKeys.size === 0)
|
|
2054
|
+
return contextPack;
|
|
2055
|
+
// Some embedders/tests provide a lightweight pack without a route decision.
|
|
2056
|
+
// The prompt still benefits from removing mismatched worked-example SQL.
|
|
2057
|
+
const existingRoute = contextPack.routeDecision;
|
|
2058
|
+
if (!existingRoute) {
|
|
2059
|
+
return {
|
|
2060
|
+
...contextPack,
|
|
2061
|
+
allowedSqlContext: {
|
|
2062
|
+
...contextPack.allowedSqlContext,
|
|
2063
|
+
sourceBlockSql: contextPack.allowedSqlContext.sourceBlockSql.filter((source) => !incompatibleBlockKeys.has(source.objectKey)),
|
|
2064
|
+
},
|
|
2065
|
+
};
|
|
2066
|
+
}
|
|
2067
|
+
const exactWasRemoved = Boolean(existingRoute.exactObjectKey
|
|
2068
|
+
&& incompatibleBlockKeys.has(existingRoute.exactObjectKey));
|
|
2069
|
+
const routeDecision = exactWasRemoved
|
|
2070
|
+
? (() => {
|
|
2071
|
+
const { exactObjectKey: _exactObjectKey, grainGate: _grainGate, ...route } = existingRoute;
|
|
2072
|
+
return {
|
|
2073
|
+
...route,
|
|
2074
|
+
route: 'generated_sql',
|
|
2075
|
+
reason: 'No certified block satisfies the requested output shape; use compatible semantic and SQL evidence.',
|
|
2076
|
+
routeReason: 'The retrieved certified candidate has a different grain or output contract and is context-only.',
|
|
2077
|
+
trustLabel: route.trustLabel === 'certified' ? 'mixed' : route.trustLabel,
|
|
2078
|
+
reviewStatus: 'draft_ready',
|
|
2079
|
+
certifiedApplicability: route.certifiedApplicability
|
|
2080
|
+
? { ...route.certifiedApplicability, kind: 'context_only' }
|
|
2081
|
+
: undefined,
|
|
2082
|
+
selectedEvidence: route.selectedEvidence,
|
|
2083
|
+
};
|
|
2084
|
+
})()
|
|
2085
|
+
: {
|
|
2086
|
+
...existingRoute,
|
|
2087
|
+
};
|
|
2088
|
+
return {
|
|
2089
|
+
...contextPack,
|
|
2090
|
+
trustLabel: exactWasRemoved && contextPack.trustLabel === 'certified' ? 'mixed' : contextPack.trustLabel,
|
|
2091
|
+
routeDecision,
|
|
2092
|
+
allowedSqlContext: {
|
|
2093
|
+
...contextPack.allowedSqlContext,
|
|
2094
|
+
sourceBlockSql: contextPack.allowedSqlContext.sourceBlockSql.filter((source) => !incompatibleBlockKeys.has(source.objectKey)),
|
|
2095
|
+
},
|
|
2096
|
+
};
|
|
2097
|
+
}
|
|
1873
2098
|
function manifestRelationDomains(manifest) {
|
|
1874
2099
|
const domains = new Map();
|
|
1875
2100
|
if (!manifest?.modeling || !manifest.dbtProvenance)
|
|
@@ -2536,6 +2761,72 @@ function cleanGeneratedSummary(text) {
|
|
|
2536
2761
|
.replace(/\s*(?:therefore,\s*)?i will generate[^.]*\.\s*/gi, ' ')
|
|
2537
2762
|
.trim();
|
|
2538
2763
|
}
|
|
2764
|
+
/**
|
|
2765
|
+
* A source-to-target flow has a strict three-field contract. Models sometimes
|
|
2766
|
+
* add a high-overlap but unrelated categorical grouping from retrieved context
|
|
2767
|
+
* (for example customer_type), which splits edge weights and corrupts both the
|
|
2768
|
+
* summary and Sankey. For a simple generated SELECT, narrow the projection and
|
|
2769
|
+
* GROUP BY to the two fields explicitly named in the question plus one
|
|
2770
|
+
* aggregate. This never invents SQL and leaves complex CTE queries untouched.
|
|
2771
|
+
*/
|
|
2772
|
+
export function tightenSourceTargetFlowProjection(sql, question, plan) {
|
|
2773
|
+
if (!/\bsankey|flow|source.?to.?target|from .+ to\b/i.test(question))
|
|
2774
|
+
return undefined;
|
|
2775
|
+
if (!/^\s*select\b/i.test(sql))
|
|
2776
|
+
return undefined;
|
|
2777
|
+
const shape = extractSimpleSelectShape(sql);
|
|
2778
|
+
if (!shape || shape.selectExpressions.length < 3)
|
|
2779
|
+
return undefined;
|
|
2780
|
+
const entries = shape.selectExpressions.map((expression) => ({
|
|
2781
|
+
expression,
|
|
2782
|
+
output: selectExpressionOutputName(expression),
|
|
2783
|
+
})).filter((entry) => Boolean(entry.output));
|
|
2784
|
+
const measureTerms = new Set(plan.requestedShape.measures.map(normalizeFlowField));
|
|
2785
|
+
const aggregates = entries.filter((entry) => /\b(?:sum|count|avg|average|min|max)\s*\(/i.test(entry.expression)
|
|
2786
|
+
|| [...measureTerms].some((measure) => flowFieldCovers(entry.output, measure)));
|
|
2787
|
+
const measure = aggregates[0];
|
|
2788
|
+
if (!measure)
|
|
2789
|
+
return undefined;
|
|
2790
|
+
const mentionedDimensions = entries
|
|
2791
|
+
.filter((entry) => entry !== measure)
|
|
2792
|
+
.map((entry) => ({ ...entry, position: flowFieldPosition(question, entry.output) }))
|
|
2793
|
+
.filter((entry) => entry.position >= 0)
|
|
2794
|
+
.sort((left, right) => left.position - right.position);
|
|
2795
|
+
const source = mentionedDimensions[0];
|
|
2796
|
+
const target = mentionedDimensions.find((entry) => entry.output !== source?.output);
|
|
2797
|
+
if (!source || !target)
|
|
2798
|
+
return undefined;
|
|
2799
|
+
const kept = [source, target, measure];
|
|
2800
|
+
const outputs = kept.map((entry) => entry.output);
|
|
2801
|
+
if (entries.length === kept.length && entries.every((entry, index) => entry.output === outputs[index])) {
|
|
2802
|
+
return { sql, outputs };
|
|
2803
|
+
}
|
|
2804
|
+
const selectMatch = /\bselect\b[\s\S]*?\bfrom\b/i.exec(sql);
|
|
2805
|
+
if (!selectMatch || selectMatch.index !== sql.search(/\bselect\b/i))
|
|
2806
|
+
return undefined;
|
|
2807
|
+
const replacement = `SELECT\n ${kept.map((entry) => entry.expression).join(',\n ')}\nFROM`;
|
|
2808
|
+
let narrowed = `${sql.slice(0, selectMatch.index)}${replacement}${sql.slice(selectMatch.index + selectMatch[0].length)}`;
|
|
2809
|
+
const groupExpressions = [source, target].map((entry) => selectSourceExpression(entry.expression));
|
|
2810
|
+
narrowed = narrowed.replace(/\bgroup\s+by\b[\s\S]*?(?=\bhaving\b|\border\s+by\b|\blimit\b|$)/i, `GROUP BY ${groupExpressions.join(', ')}`);
|
|
2811
|
+
return { sql: narrowed.trim(), outputs };
|
|
2812
|
+
}
|
|
2813
|
+
function selectSourceExpression(expression) {
|
|
2814
|
+
return expression
|
|
2815
|
+
.replace(/\s+as\s+["`]?\w+["`]?\s*$/i, '')
|
|
2816
|
+
.trim();
|
|
2817
|
+
}
|
|
2818
|
+
function flowFieldPosition(question, field) {
|
|
2819
|
+
const normalizedQuestion = normalizeFlowField(question);
|
|
2820
|
+
const normalizedField = normalizeFlowField(field);
|
|
2821
|
+
return normalizedField ? normalizedQuestion.indexOf(normalizedField) : -1;
|
|
2822
|
+
}
|
|
2823
|
+
function flowFieldCovers(field, term) {
|
|
2824
|
+
const normalizedField = normalizeFlowField(field);
|
|
2825
|
+
return normalizedField === term || normalizedField.split(' ').includes(term);
|
|
2826
|
+
}
|
|
2827
|
+
function normalizeFlowField(value) {
|
|
2828
|
+
return value.toLowerCase().replace(/[_-]+/g, ' ').replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
2829
|
+
}
|
|
2539
2830
|
/**
|
|
2540
2831
|
* Public for tests. Prefer the structured W2.7 JSON proposal contract, then
|
|
2541
2832
|
* fall back to the legacy prose + ```sql block + Viz line format.
|
|
@@ -2967,7 +3258,7 @@ function pickCertifiedArtifact(input) {
|
|
|
2967
3258
|
}
|
|
2968
3259
|
}
|
|
2969
3260
|
const executableHit = pickFirstCertifiedHit(input.executableArtifactHits, input.kg, input.excludedArtifactIds, input.question, input.questionPlan);
|
|
2970
|
-
if (
|
|
3261
|
+
if (isPureBusinessDefinitionQuestion(input.question)) {
|
|
2971
3262
|
if (executableHit && hasExactExecutableArtifactSignal(input.question, executableHit.node)) {
|
|
2972
3263
|
return executableHit;
|
|
2973
3264
|
}
|
|
@@ -3074,6 +3365,10 @@ function isCertifiedHit(hit, kg) {
|
|
|
3074
3365
|
function isBusinessDefinitionQuestion(question) {
|
|
3075
3366
|
return /\b(what is|what are|define|definition|meaning of|what does .+ mean)\b/i.test(question);
|
|
3076
3367
|
}
|
|
3368
|
+
/** Definition that may terminate with documentation instead of executing data. */
|
|
3369
|
+
function isPureBusinessDefinitionQuestion(question) {
|
|
3370
|
+
return questionTypeFromText(question) === 'definition';
|
|
3371
|
+
}
|
|
3077
3372
|
function isBreakdownOrDrilldownQuestion(question) {
|
|
3078
3373
|
return /\b(break\s*down|breakdown|drill\s*(?:down|into)|slice|segment|split|by\s+[a-z][\w\s-]{1,40})\b/i.test(question);
|
|
3079
3374
|
}
|
|
@@ -3128,7 +3423,7 @@ function hasCompatibleCertifiedBlockMatch(question, node) {
|
|
|
3128
3423
|
function hasCertifiedNodeFit(question, plan, node, options = {}) {
|
|
3129
3424
|
if (!hasCompatibleRankingDirection(question, node))
|
|
3130
3425
|
return false;
|
|
3131
|
-
const definitionLookup =
|
|
3426
|
+
const definitionLookup = isPureBusinessDefinitionQuestion(question) && objectNameInQuestion(question, node);
|
|
3132
3427
|
const exactExampleMatch = (node.examples ?? []).some((example) => normalizeQuestion(example.question) === normalizeQuestion(question));
|
|
3133
3428
|
const exactObjectRequest = objectNameInQuestion(question, node)
|
|
3134
3429
|
&& /\b(run|use|open|show|execute|certified|saved|block)\b/i.test(question);
|
|
@@ -3818,6 +4113,18 @@ function summarizeEvidencePayload(value, maxLength = 700) {
|
|
|
3818
4113
|
function isRetryableGeneratedSqlError(error) {
|
|
3819
4114
|
return !/\b(read-only|readonly|select or with|unsafe|delete|insert|update|drop|alter|create|attach|copy|pragma)\b/i.test(error);
|
|
3820
4115
|
}
|
|
4116
|
+
/**
|
|
4117
|
+
* Only SQL-shape/binding failures may leave the certified lane. Connectivity,
|
|
4118
|
+
* authorization, cancellation, and timeout failures would affect every query
|
|
4119
|
+
* route and must remain terminal rather than triggering wasteful provider work.
|
|
4120
|
+
*/
|
|
4121
|
+
function isRetryableCertifiedExecutionError(error) {
|
|
4122
|
+
return /\b(?:binder|parser|catalog)\s+error\b/i.test(error)
|
|
4123
|
+
|| /\bambiguous\s+reference\b/i.test(error)
|
|
4124
|
+
|| /\breferenced\s+column\b.*\bnot\s+found\b/i.test(error)
|
|
4125
|
+
|| /\bcolumn\b.*\b(?:not\s+found|does\s+not\s+exist|not\s+recognized|unknown)\b/i.test(error)
|
|
4126
|
+
|| /\btable\b.*\b(?:not\s+found|does\s+not\s+exist|unknown)\b/i.test(error);
|
|
4127
|
+
}
|
|
3821
4128
|
function repairGeneratedSqlLocally(sql, error, schemaContext) {
|
|
3822
4129
|
const missing = error.match(/(?:Values list|Referenced table)\s+"([^"]+)"\s+does not have a column named\s+"([^"]+)"/i)
|
|
3823
4130
|
?? error.match(/Referenced column\s+"([^"]+)"\s+not found/i);
|
|
@@ -4005,9 +4312,10 @@ function interleaveContextHits(groups, budget) {
|
|
|
4005
4312
|
}
|
|
4006
4313
|
/**
|
|
4007
4314
|
* Candidate metric KG nodes for semantic-metric matching (spec 17, part C).
|
|
4008
|
-
* Starts with the FTS semantic + considered hits, then folds in
|
|
4315
|
+
* Starts with the FTS semantic + considered hits, then folds in every metric
|
|
4009
4316
|
* node from the KG so a confident measure-family match is found even when FTS
|
|
4010
|
-
* surfaced no metric at all
|
|
4317
|
+
* surfaced no metric at all. Metric headers are compact; correctness must not
|
|
4318
|
+
* depend on an alphabetical first-200 slice in an enterprise catalog.
|
|
4011
4319
|
*/
|
|
4012
4320
|
function collectMetricCandidates(semanticHits, considered, kg) {
|
|
4013
4321
|
const byId = new Map();
|
|
@@ -4016,7 +4324,7 @@ function collectMetricCandidates(semanticHits, considered, kg) {
|
|
|
4016
4324
|
byId.set(hit.node.nodeId, hit.node);
|
|
4017
4325
|
}
|
|
4018
4326
|
try {
|
|
4019
|
-
for (const node of kg.getNodesByKind('metric',
|
|
4327
|
+
for (const node of kg.getNodesByKind('metric', 100_000)) {
|
|
4020
4328
|
if (!byId.has(node.nodeId))
|
|
4021
4329
|
byId.set(node.nodeId, node);
|
|
4022
4330
|
}
|
|
@@ -4026,6 +4334,30 @@ function collectMetricCandidates(semanticHits, considered, kg) {
|
|
|
4026
4334
|
}
|
|
4027
4335
|
return Array.from(byId.values());
|
|
4028
4336
|
}
|
|
4337
|
+
/**
|
|
4338
|
+
* Bind a validated meaning-resolution ID to the exact KG metric node. This is
|
|
4339
|
+
* deliberately ID-only: labels/aliases are not reinterpreted here, and the
|
|
4340
|
+
* semantic compiler still has to prove the requested dimensions and filters.
|
|
4341
|
+
*/
|
|
4342
|
+
function resolvePreferredSemanticMetric(ids, pool, kg) {
|
|
4343
|
+
const byId = new Map(pool.filter((node) => node.kind === 'metric').map((node) => [node.nodeId, node]));
|
|
4344
|
+
for (const rawId of ids) {
|
|
4345
|
+
const id = rawId?.trim();
|
|
4346
|
+
if (!id)
|
|
4347
|
+
continue;
|
|
4348
|
+
const nodeId = id.startsWith('semantic:metric:')
|
|
4349
|
+
? id.slice('semantic:'.length)
|
|
4350
|
+
: id.startsWith('metric:')
|
|
4351
|
+
? id
|
|
4352
|
+
: undefined;
|
|
4353
|
+
if (!nodeId)
|
|
4354
|
+
continue;
|
|
4355
|
+
const node = byId.get(nodeId) ?? kg.getNode(nodeId);
|
|
4356
|
+
if (node?.kind === 'metric')
|
|
4357
|
+
return node;
|
|
4358
|
+
}
|
|
4359
|
+
return undefined;
|
|
4360
|
+
}
|
|
4029
4361
|
function buildCertifiedEvidence(input) {
|
|
4030
4362
|
const businessContextAssets = uniqueAssets(input.businessHits
|
|
4031
4363
|
.map((hit) => hit.node)
|