@duckcodeailabs/dql-agent 1.6.33 → 1.7.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 +39 -8
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +169 -10
- 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 +21 -3
- 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 -3
- package/dist/agent-run-planner.js.map +1 -1
- package/dist/agentic/answer-contract.d.ts +37 -0
- package/dist/agentic/answer-contract.d.ts.map +1 -0
- package/dist/agentic/answer-contract.js +47 -0
- package/dist/agentic/answer-contract.js.map +1 -0
- package/dist/agentic/tool-loop.d.ts +48 -0
- package/dist/agentic/tool-loop.d.ts.map +1 -0
- package/dist/agentic/tool-loop.js +181 -0
- package/dist/agentic/tool-loop.js.map +1 -0
- package/dist/agentic/toolset.d.ts +40 -0
- package/dist/agentic/toolset.d.ts.map +1 -0
- package/dist/agentic/toolset.js +265 -0
- package/dist/agentic/toolset.js.map +1 -0
- package/dist/answer-loop.d.ts +82 -6
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +799 -112
- package/dist/answer-loop.js.map +1 -1
- package/dist/app-builder.d.ts.map +1 -1
- package/dist/app-builder.js +18 -10
- package/dist/app-builder.js.map +1 -1
- package/dist/cascade/budgets.d.ts +20 -2
- package/dist/cascade/budgets.d.ts.map +1 -1
- package/dist/cascade/budgets.js +83 -45
- package/dist/cascade/budgets.js.map +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 +6 -0
- package/dist/cascade/route-policy.js.map +1 -1
- package/dist/domain-context.d.ts +30 -0
- package/dist/domain-context.d.ts.map +1 -0
- package/dist/domain-context.js +68 -0
- package/dist/domain-context.js.map +1 -0
- package/dist/embeddings/provider.d.ts +67 -4
- package/dist/embeddings/provider.d.ts.map +1 -1
- package/dist/embeddings/provider.js +167 -6
- package/dist/embeddings/provider.js.map +1 -1
- package/dist/hints/correction-eval.d.ts +30 -0
- package/dist/hints/correction-eval.d.ts.map +1 -0
- package/dist/hints/correction-eval.js +63 -0
- package/dist/hints/correction-eval.js.map +1 -0
- package/dist/hints/git-store.d.ts +39 -3
- package/dist/hints/git-store.d.ts.map +1 -1
- package/dist/hints/git-store.js +172 -2
- package/dist/hints/git-store.js.map +1 -1
- package/dist/hints/staleness.d.ts +30 -0
- package/dist/hints/staleness.d.ts.map +1 -0
- package/dist/hints/staleness.js +38 -0
- package/dist/hints/staleness.js.map +1 -0
- package/dist/hints/store.d.ts.map +1 -1
- package/dist/hints/store.js +3 -32
- package/dist/hints/store.js.map +1 -1
- package/dist/hints/types.d.ts +41 -0
- package/dist/hints/types.d.ts.map +1 -1
- package/dist/hints/types.js +1 -0
- package/dist/hints/types.js.map +1 -1
- package/dist/index.d.ts +46 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +180 -9
- package/dist/index.js.map +1 -1
- package/dist/intent-controller.d.ts.map +1 -1
- package/dist/intent-controller.js +15 -3
- package/dist/intent-controller.js.map +1 -1
- package/dist/kg/build.d.ts.map +1 -1
- package/dist/kg/build.js +196 -4
- package/dist/kg/build.js.map +1 -1
- package/dist/kg/sqlite-fts.d.ts +13 -0
- package/dist/kg/sqlite-fts.d.ts.map +1 -1
- package/dist/kg/sqlite-fts.js +82 -49
- package/dist/kg/sqlite-fts.js.map +1 -1
- package/dist/kg/types.d.ts +9 -2
- package/dist/kg/types.d.ts.map +1 -1
- package/dist/memory/fts-query.d.ts +37 -4
- package/dist/memory/fts-query.d.ts.map +1 -1
- package/dist/memory/fts-query.js +53 -12
- package/dist/memory/fts-query.js.map +1 -1
- package/dist/metadata/analysis-planner.d.ts.map +1 -1
- package/dist/metadata/analysis-planner.js +179 -28
- package/dist/metadata/analysis-planner.js.map +1 -1
- package/dist/metadata/analytical-policy.d.ts +51 -0
- package/dist/metadata/analytical-policy.d.ts.map +1 -0
- package/dist/metadata/analytical-policy.js +309 -0
- package/dist/metadata/analytical-policy.js.map +1 -0
- package/dist/metadata/block-adapt.d.ts +37 -0
- package/dist/metadata/block-adapt.d.ts.map +1 -0
- package/dist/metadata/block-adapt.js +89 -0
- package/dist/metadata/block-adapt.js.map +1 -0
- package/dist/metadata/block-fit.js +37 -4
- package/dist/metadata/block-fit.js.map +1 -1
- package/dist/metadata/catalog.d.ts +85 -1
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +674 -92
- package/dist/metadata/catalog.js.map +1 -1
- package/dist/metadata/dbt-first-safety.d.ts +19 -0
- package/dist/metadata/dbt-first-safety.d.ts.map +1 -0
- package/dist/metadata/dbt-first-safety.js +17 -0
- package/dist/metadata/dbt-first-safety.js.map +1 -0
- package/dist/metadata/example-match.d.ts +30 -0
- package/dist/metadata/example-match.d.ts.map +1 -0
- package/dist/metadata/example-match.js +69 -0
- package/dist/metadata/example-match.js.map +1 -0
- package/dist/metadata/grain-ledger.d.ts +51 -0
- package/dist/metadata/grain-ledger.d.ts.map +1 -0
- package/dist/metadata/grain-ledger.js +174 -0
- package/dist/metadata/grain-ledger.js.map +1 -0
- package/dist/metadata/metric-match.d.ts +21 -1
- package/dist/metadata/metric-match.d.ts.map +1 -1
- package/dist/metadata/metric-match.js +157 -21
- package/dist/metadata/metric-match.js.map +1 -1
- package/dist/metadata/sql-context-validation.d.ts.map +1 -1
- package/dist/metadata/sql-context-validation.js +42 -2
- package/dist/metadata/sql-context-validation.js.map +1 -1
- package/dist/metadata/sql-grounding.d.ts +4 -2
- package/dist/metadata/sql-grounding.d.ts.map +1 -1
- package/dist/metadata/sql-grounding.js +29 -4
- package/dist/metadata/sql-grounding.js.map +1 -1
- package/dist/metadata/sql-retrieval.d.ts +5 -2
- package/dist/metadata/sql-retrieval.d.ts.map +1 -1
- package/dist/metadata/sql-retrieval.js +28 -6
- package/dist/metadata/sql-retrieval.js.map +1 -1
- package/dist/propose/build-from-prompt.d.ts +34 -0
- package/dist/propose/build-from-prompt.d.ts.map +1 -1
- package/dist/propose/build-from-prompt.js +205 -19
- package/dist/propose/build-from-prompt.js.map +1 -1
- package/dist/propose/failure-analysis.d.ts +48 -0
- package/dist/propose/failure-analysis.d.ts.map +1 -0
- package/dist/propose/failure-analysis.js +71 -0
- package/dist/propose/failure-analysis.js.map +1 -0
- package/dist/propose/join-mining.d.ts +18 -0
- package/dist/propose/join-mining.d.ts.map +1 -0
- package/dist/propose/join-mining.js +74 -0
- package/dist/propose/join-mining.js.map +1 -0
- package/dist/providers/claude.d.ts.map +1 -1
- package/dist/providers/claude.js +36 -1
- package/dist/providers/claude.js.map +1 -1
- package/dist/providers/index.d.ts +2 -2
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +1 -1
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +34 -1
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/reasoning-effort.d.ts +27 -0
- package/dist/providers/reasoning-effort.d.ts.map +1 -1
- package/dist/providers/reasoning-effort.js +32 -0
- package/dist/providers/reasoning-effort.js.map +1 -1
- package/dist/providers/types.d.ts +2 -1
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/research-loop.d.ts.map +1 -1
- package/dist/research-loop.js +9 -2
- package/dist/research-loop.js.map +1 -1
- package/dist/semantic-bridge/compose.d.ts.map +1 -1
- package/dist/semantic-bridge/compose.js +316 -25
- package/dist/semantic-bridge/compose.js.map +1 -1
- package/dist/skills/bootstrap.d.ts +57 -0
- package/dist/skills/bootstrap.d.ts.map +1 -0
- package/dist/skills/bootstrap.js +260 -0
- package/dist/skills/bootstrap.js.map +1 -0
- package/dist/skills/defaults.d.ts +2 -2
- package/dist/skills/defaults.js +2 -2
- package/dist/skills/domain-skills.d.ts +24 -0
- package/dist/skills/domain-skills.d.ts.map +1 -0
- package/dist/skills/domain-skills.js +76 -0
- package/dist/skills/domain-skills.js.map +1 -0
- package/dist/skills/loader.d.ts +56 -8
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +257 -41
- package/dist/skills/loader.js.map +1 -1
- package/dist/synthesize.d.ts.map +1 -1
- package/dist/synthesize.js +152 -16
- package/dist/synthesize.js.map +1 -1
- package/dist/tools/registry.d.ts +1 -1
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +54 -12
- package/dist/tools/registry.js.map +1 -1
- package/package.json +4 -4
package/dist/answer-loop.js
CHANGED
|
@@ -14,22 +14,31 @@
|
|
|
14
14
|
* step. Tests can mock the provider with a canned response and exercise the
|
|
15
15
|
* full pipeline.
|
|
16
16
|
*/
|
|
17
|
-
import {
|
|
17
|
+
import { describeDialectForPrompt, } from '@duckcodeailabs/dql-core';
|
|
18
|
+
import { buildSkillBlockHints, buildSkillMetricHints, buildSkillsPrompt, expandQuestionWithSkillVocabulary, selectRelevantSkills } from './skills/loader.js';
|
|
19
|
+
import { domainContextSearchDomains } from './domain-context.js';
|
|
18
20
|
import { deriveGeneratedDraftSlug, renderGeneratedSqlDqlArtifact } from './metadata/drafts.js';
|
|
19
21
|
import { buildAnalysisQuestionPlan } from './metadata/analysis-planner.js';
|
|
20
22
|
import { certifiedFitAllowsTier1, evaluateCertifiedBlockFit } from './metadata/block-fit.js';
|
|
21
|
-
import { buildGovernedMetricFirstSql, matchSemanticMetric, resolveGovernedMetricSql } from './metadata/metric-match.js';
|
|
23
|
+
import { buildGovernedMetricFirstSql, matchSemanticMetric, resolveGovernedMetricDefinition, resolveGovernedMetricSql } from './metadata/metric-match.js';
|
|
22
24
|
import { decideAgentAction } from './intent-controller.js';
|
|
23
25
|
import { createContextLedger } from './grounding/context-ledger.js';
|
|
24
26
|
import { validateAnswerResultShape } from './answer-shape.js';
|
|
27
|
+
import { fanoutWarningsForSql } from './metadata/grain-ledger.js';
|
|
28
|
+
import { evaluateDbtFirstGeneratedSql } from './metadata/dbt-first-safety.js';
|
|
29
|
+
import { planAnalyticalPath } from './metadata/analytical-policy.js';
|
|
30
|
+
import { planCertifiedAdaptation } from './metadata/block-adapt.js';
|
|
25
31
|
import { compactSqlSnippet, extractSimpleSelectShape, selectExpressionOutputName, } from './metadata/sql-shape.js';
|
|
26
32
|
import { composeSemanticQueryForQuestion, composeSemanticQueryFromMembers } from './semantic-bridge/compose.js';
|
|
33
|
+
import { runAgenticToolLoop } from './agentic/tool-loop.js';
|
|
34
|
+
import { buildSemanticStageTools } from './agentic/toolset.js';
|
|
35
|
+
import { deriveAgenticTrust } from './agentic/answer-contract.js';
|
|
27
36
|
import { selectSemanticMembersViaLlm } from './semantic-bridge/member-select.js';
|
|
28
37
|
import { normalizeValueIndexText } from './grounding/value-index.js';
|
|
29
38
|
import { cascadeTraceToEvidenceRouteSteps, createCascadeAnswerResult, createCascadeTrace, } from './cascade/cascade.js';
|
|
30
39
|
import { shouldClarifyBeforeGeneration } from './cascade/triage.js';
|
|
31
40
|
import { stampTrustLabel } from './trust/stamp.js';
|
|
32
|
-
import { QUICK_PROMPT_CONTEXT_BUDGET, canUseLaneRepair, cascadeBudgetTrace, createCascadeBudgetState, promptContextBudgetForQuestion, proposalToolBudgetForQuestion, recordLaneRepair, } from './cascade/budgets.js';
|
|
41
|
+
import { QUICK_PROMPT_CONTEXT_BUDGET, canUseLaneRepair, cascadeBudgetTrace, createCascadeBudgetState, deepAlternativeCountForQuestion, promptContextBudgetForQuestion, proposalToolBudgetForQuestion, recordLaneRepair, } from './cascade/budgets.js';
|
|
33
42
|
/**
|
|
34
43
|
* Physical column names whose sampled runtime values include `value`. Used by the
|
|
35
44
|
* semantic bridge to bind a filter literal to the dimension that actually carries
|
|
@@ -57,7 +66,7 @@ const EXECUTABLE_ARTIFACT_KINDS = ['block', 'dashboard', 'app', 'notebook'];
|
|
|
57
66
|
// be cited, but they never produce the row-level answer to an analytical question,
|
|
58
67
|
// so they must not terminate a data ask as a "certified answer" with no data.
|
|
59
68
|
const NAVIGATION_ARTIFACT_KINDS = ['dashboard', 'app', 'notebook'];
|
|
60
|
-
const BUSINESS_CONTEXT_KINDS = ['term', 'business_view'];
|
|
69
|
+
const BUSINESS_CONTEXT_KINDS = ['term', 'business_view', 'domain', 'skill', 'relationship', 'contract', 'domain_export', 'domain_import', 'conformance', 'evaluation'];
|
|
61
70
|
const ARTIFACT_KINDS = [...EXECUTABLE_ARTIFACT_KINDS, ...BUSINESS_CONTEXT_KINDS];
|
|
62
71
|
const SEMANTIC_KINDS = ['metric', 'dimension', 'measure', 'entity', 'semantic_model', 'saved_query'];
|
|
63
72
|
const MANIFEST_KINDS = ['dbt_model', 'dbt_source'];
|
|
@@ -69,6 +78,46 @@ function refusalCodeForValidation(code) {
|
|
|
69
78
|
return 'ambiguous';
|
|
70
79
|
return 'model_declined';
|
|
71
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* An analytical-policy result is never a generic grounding gap. A missing
|
|
83
|
+
* relation/path can enter the bounded exploratory lane; every other result is
|
|
84
|
+
* an explicit governance boundary that the host must surface without retrying
|
|
85
|
+
* the same candidate or asking the user a misleading clarification question.
|
|
86
|
+
*/
|
|
87
|
+
function refusalCodeForAnalyticalPolicy(code, hasExploratoryCandidate = false) {
|
|
88
|
+
if (hasExploratoryCandidate || code === 'unbound_relation' || code === 'unplanned_join' || code === 'relationship_not_certified') {
|
|
89
|
+
return 'grounding_gap';
|
|
90
|
+
}
|
|
91
|
+
return 'policy_blocked';
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The governed guard is intentionally strict. Only the two outcomes that mean
|
|
95
|
+
* "this repository has not modeled this join yet" may be handed to a host's
|
|
96
|
+
* exploratory lane. All other decisions are explicit governance/safety
|
|
97
|
+
* denials and must remain terminal in this loop.
|
|
98
|
+
*/
|
|
99
|
+
function exploratoryCandidateFromDbtFirstGuard(sql, decision) {
|
|
100
|
+
const reason = decision.code;
|
|
101
|
+
if (decision.safe || (reason !== 'unbound_relation' && reason !== 'unplanned_join' && reason !== 'relationship_not_certified')) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
// `unplanned_join` can also mean the model ignored an existing certified
|
|
105
|
+
// relationship plan. That is a governed-query error, not missing modeling,
|
|
106
|
+
// and must remain blocked. It is exploratory only when no plan was resolved.
|
|
107
|
+
if (decision.code === 'unplanned_join' && decision.relationshipIds.length > 0) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
kind: 'dbt_grounded_exploration',
|
|
112
|
+
reason,
|
|
113
|
+
sql,
|
|
114
|
+
message: decision.message
|
|
115
|
+
?? 'This query is grounded in dbt metadata but has no certified DQL relationship path yet.',
|
|
116
|
+
modeledEntityIds: decision.entities,
|
|
117
|
+
relationshipIds: decision.relationshipIds,
|
|
118
|
+
executionStatus: 'not_executed',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
72
121
|
function formatOffendingValidationToken(offending) {
|
|
73
122
|
if (!offending?.relation && !offending?.column)
|
|
74
123
|
return '';
|
|
@@ -157,10 +206,13 @@ export async function answer(input) {
|
|
|
157
206
|
},
|
|
158
207
|
isFollowUp: Boolean(input.followUp),
|
|
159
208
|
});
|
|
209
|
+
const trustLabelInfo = stampTrustLabel(result);
|
|
160
210
|
return {
|
|
161
211
|
...publicResult,
|
|
212
|
+
domainContext: input.domainContext,
|
|
162
213
|
intentDecision,
|
|
163
|
-
trustLabelInfo
|
|
214
|
+
trustLabelInfo,
|
|
215
|
+
provenanceFooter: buildProvenanceFooter(result, trustLabelInfo),
|
|
164
216
|
cascade: publicResult.cascade ?? createCascadeAnswerResult({
|
|
165
217
|
routeTier: chosenRoute.tier,
|
|
166
218
|
label: chosenRoute.label,
|
|
@@ -178,7 +230,15 @@ export async function answer(input) {
|
|
|
178
230
|
// Stamp the SELECTED skills that shaped the answer (transparency). Computed
|
|
179
231
|
// here so every return site inside runAnswerLoop stays untouched.
|
|
180
232
|
appliedSkills: result.appliedSkills ??
|
|
181
|
-
selectRelevantSkills(input.skills ?? [], input.question, {
|
|
233
|
+
selectRelevantSkills(input.skills ?? [], input.question, {
|
|
234
|
+
userId: input.userId ?? null,
|
|
235
|
+
modelAreaIds: input.domainContext?.modelAreaId ? [input.domainContext.modelAreaId] : [],
|
|
236
|
+
domains: Array.from(new Set([
|
|
237
|
+
...domainContextSearchDomains(input.domainContext),
|
|
238
|
+
...(input.domain ? [input.domain] : []),
|
|
239
|
+
...(input.contextPack?.objects ?? []).slice(0, 20).flatMap((object) => object.domain ? [object.domain] : []),
|
|
240
|
+
])),
|
|
241
|
+
}).map((s) => ({
|
|
182
242
|
id: s.id,
|
|
183
243
|
description: s.description,
|
|
184
244
|
})),
|
|
@@ -205,6 +265,12 @@ function cascadeExecutionStatus(result) {
|
|
|
205
265
|
*/
|
|
206
266
|
function deriveAiRoute(result, metricMatch) {
|
|
207
267
|
if (result.kind === 'no_answer') {
|
|
268
|
+
if (result.exploratoryCandidate) {
|
|
269
|
+
return {
|
|
270
|
+
tier: 'no_answer',
|
|
271
|
+
label: 'Governed SQL stopped at missing relationship modeling; a DBT-grounded exploratory candidate is ready for bounded validation.',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
208
274
|
return { tier: 'no_answer', label: 'No governed answer — needs more context or review.' };
|
|
209
275
|
}
|
|
210
276
|
if (result.kind === 'certified') {
|
|
@@ -237,27 +303,60 @@ function deriveAiRoute(result, metricMatch) {
|
|
|
237
303
|
}
|
|
238
304
|
async function runAnswerLoop(input) {
|
|
239
305
|
const { question, userId, domain, provider, kg, skills = [], blockHints = [] } = input;
|
|
306
|
+
// AGT-004: with no explicit domain selection, let direct question evidence
|
|
307
|
+
// establish a narrow prompt boundary before broad retrieval can introduce an
|
|
308
|
+
// unrelated domain. This is deliberately a prompt/retrieval preference, not
|
|
309
|
+
// an authorization decision; the final manifest guard remains authoritative.
|
|
310
|
+
const directQuestionEntityIds = input.manifest
|
|
311
|
+
? inferAnalyticalEntityIds(question, [], input.manifest)
|
|
312
|
+
: [];
|
|
313
|
+
const directQuestionDomains = input.manifest?.modeling
|
|
314
|
+
? Array.from(new Set(directQuestionEntityIds.map((id) => input.manifest.modeling.entities[id]?.domain).filter((value) => Boolean(value))))
|
|
315
|
+
: [];
|
|
316
|
+
const hasExplicitDomainScope = Boolean(input.domain || input.domainContext?.activeDomain);
|
|
317
|
+
const questionDomainScope = hasExplicitDomainScope ? [] : directQuestionDomains;
|
|
318
|
+
const promptContextPack = questionDomainScope.length > 0
|
|
319
|
+
? scopeContextPackToQuestionDomains(input.contextPack, questionDomainScope, input.manifest)
|
|
320
|
+
: input.contextPack;
|
|
240
321
|
// Select the RELEVANT skills (not all) for this question; keep pinned project
|
|
241
322
|
// skills (SQL conventions). Block hints still come from the full set so a
|
|
242
323
|
// preferred-block mapping is never lost.
|
|
243
|
-
const
|
|
324
|
+
const authorizedDomains = domainContextSearchDomains(input.domainContext);
|
|
325
|
+
const inferredDomains = Array.from(new Set([
|
|
326
|
+
...authorizedDomains,
|
|
327
|
+
...questionDomainScope,
|
|
328
|
+
...(domain ? [domain] : []),
|
|
329
|
+
...(questionDomainScope.length === 0
|
|
330
|
+
? (input.contextPack?.objects ?? []).slice(0, 20).flatMap((object) => object.domain ? [object.domain] : [])
|
|
331
|
+
: []),
|
|
332
|
+
]));
|
|
333
|
+
const selectedSkills = selectRelevantSkills(skills, question, {
|
|
334
|
+
userId: userId ?? null,
|
|
335
|
+
domains: inferredDomains,
|
|
336
|
+
modelAreaIds: input.domainContext?.modelAreaId ? [input.domainContext.modelAreaId] : [],
|
|
337
|
+
});
|
|
244
338
|
const effectiveBlockHints = Array.from(new Set([
|
|
245
339
|
...blockHints,
|
|
246
|
-
|
|
340
|
+
// Only selected skills may influence block ranking. Previously a preferred
|
|
341
|
+
// block from any active but unrelated domain skill could jump to the front.
|
|
342
|
+
...buildSkillBlockHints(selectedSkills, userId ?? null),
|
|
247
343
|
]));
|
|
344
|
+
const effectiveMetricHints = buildSkillMetricHints(selectedSkills, userId ?? null);
|
|
345
|
+
const semanticQuestion = expandQuestionWithSkillVocabulary(question, selectedSkills, userId ?? null);
|
|
248
346
|
const followUpSourceBlock = input.followUp?.sourceBlockName
|
|
249
347
|
? kg.getNode(`block:${input.followUp.sourceBlockName}`)
|
|
250
348
|
: null;
|
|
251
349
|
const excludedArtifactIds = input.followUp?.kind === 'drilldown' && followUpSourceBlock
|
|
252
350
|
? new Set([followUpSourceBlock.nodeId])
|
|
253
351
|
: undefined;
|
|
254
|
-
const
|
|
255
|
-
const
|
|
352
|
+
const searchScope = authorizedDomains.length > 0 ? { domains: authorizedDomains } : { domain };
|
|
353
|
+
const executableArtifactHits = kg.search({ query: question, ...searchScope, kinds: EXECUTABLE_ARTIFACT_KINDS, limit: 10 });
|
|
354
|
+
const businessHits = kg.search({ query: question, ...searchScope, kinds: BUSINESS_CONTEXT_KINDS, limit: 10 });
|
|
256
355
|
const artifactHits = mergeHits(executableArtifactHits, businessHits).slice(0, 12);
|
|
257
|
-
const semanticHits = kg.search({ query: question,
|
|
258
|
-
const manifestHits = kg.search({ query: question,
|
|
356
|
+
const semanticHits = kg.search({ query: question, ...searchScope, kinds: SEMANTIC_KINDS, limit: 12 });
|
|
357
|
+
const manifestHits = kg.search({ query: question, ...searchScope, kinds: MANIFEST_KINDS, limit: 12 });
|
|
259
358
|
const considered = mergeHits(artifactHits, semanticHits, manifestHits, kg.search({ query: question, domain, limit: 10 })).slice(0, 30);
|
|
260
|
-
const schemaContext = schemaContextWithAllowedSqlContext(input.schemaContext ?? [], input.contextPack);
|
|
359
|
+
const schemaContext = schemaContextWithAllowedSqlContext(schemaContextWithinQuestionScope(input.schemaContext ?? [], input.contextPack, promptContextPack), promptContextPack);
|
|
261
360
|
const catalogRoute = input.contextPack?.routeDecision;
|
|
262
361
|
const questionPlan = input.contextPack?.questionPlan?.requestedShape
|
|
263
362
|
? input.contextPack.questionPlan
|
|
@@ -272,6 +371,19 @@ async function runAnswerLoop(input) {
|
|
|
272
371
|
schemaContext,
|
|
273
372
|
});
|
|
274
373
|
let intent = catalogRoute ? agentIntentFromCatalogRoute(catalogRoute) : fallbackIntent;
|
|
374
|
+
// Resolve a governed metric before accepting a catalog-proposed certified
|
|
375
|
+
// block. Catalog token overlap can call a generic word such as "total" an
|
|
376
|
+
// exact block match (for example total tax → total revenue); the metric match
|
|
377
|
+
// is the precise measure signal that prevents that wrong Tier-1 shortcut.
|
|
378
|
+
const semanticMetricNodes = collectMetricCandidates(semanticHits, considered, kg);
|
|
379
|
+
for (const metric of effectiveMetricHints) {
|
|
380
|
+
const node = kg.getNode(`metric:${metric}`);
|
|
381
|
+
if (node && !semanticMetricNodes.some((candidate) => candidate.nodeId === node.nodeId))
|
|
382
|
+
semanticMetricNodes.push(node);
|
|
383
|
+
}
|
|
384
|
+
let semanticMetricMatch = await matchSemanticMetric(semanticQuestion, semanticMetricNodes, {
|
|
385
|
+
measureTerms: [...questionPlan.requestedShape.measures, ...questionPlan.metricTerms],
|
|
386
|
+
}).catch(() => null);
|
|
275
387
|
// Stage 1: certified artifact match. Blocks can be executed; dashboards,
|
|
276
388
|
// Apps, and notebooks are returned as governed citations/navigation targets.
|
|
277
389
|
const drilldownCertifiedHit = input.followUp?.kind === 'drilldown'
|
|
@@ -284,19 +396,29 @@ async function runAnswerLoop(input) {
|
|
|
284
396
|
kg,
|
|
285
397
|
})
|
|
286
398
|
: null;
|
|
287
|
-
const
|
|
399
|
+
const shouldTryCertifiedRoute = shouldUseCertifiedRoute(catalogRoute, intent);
|
|
400
|
+
const catalogCertifiedHit = shouldTryCertifiedRoute
|
|
288
401
|
? certifiedHitFromContextPack(input.contextPack, kg)
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
402
|
+
: null;
|
|
403
|
+
const unsafeCatalogCertifiedHit = catalogCertifiedHit?.node.kind === 'block' && semanticMetricMatch
|
|
404
|
+
&& !hasCertifiedNodeFit(question, questionPlan, catalogCertifiedHit.node)
|
|
405
|
+
? null
|
|
406
|
+
: catalogCertifiedHit;
|
|
407
|
+
const fallbackCertifiedHit = shouldTryCertifiedRoute ? pickCertifiedArtifact({
|
|
408
|
+
artifactHits,
|
|
409
|
+
executableArtifactHits,
|
|
410
|
+
businessHits,
|
|
411
|
+
question,
|
|
412
|
+
questionPlan,
|
|
413
|
+
blockHints: input.followUp?.kind === 'drilldown' ? [] : effectiveBlockHints,
|
|
414
|
+
excludedArtifactIds,
|
|
415
|
+
kg,
|
|
416
|
+
}) : null;
|
|
417
|
+
let artifactHit = drilldownCertifiedHit ?? unsafeCatalogCertifiedHit
|
|
418
|
+
?? (catalogCertifiedHit ? null : fallbackCertifiedHit);
|
|
419
|
+
// Certified remains first when it actually covers the question. If the
|
|
420
|
+
// retrieved block does not fit but a governed semantic metric does, never
|
|
421
|
+
// let the broad catalog match pre-empt Lane 2.
|
|
300
422
|
// A certified TERM / BUSINESS VIEW is documentation, not data: it can be the
|
|
301
423
|
// terminal answer only for a definition-style question with no data ask. A
|
|
302
424
|
// question that requests a data shape (dimensions/measures/outputs/top-N) or
|
|
@@ -322,30 +444,80 @@ async function runAnswerLoop(input) {
|
|
|
322
444
|
if (NAVIGATION_ARTIFACT_KINDS.includes(artifactHit.node.kind)) {
|
|
323
445
|
return objectNameInQuestion(question, artifactHit.node);
|
|
324
446
|
}
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
// "
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const strongShape = nonTrivialOutputs.length > 0
|
|
333
|
-
|| requested.filters.length > 0
|
|
334
|
-
|| Boolean(requested.topN);
|
|
335
|
-
return !strongShape;
|
|
447
|
+
// Terms, skills, domains, relationships, and business views are grounding
|
|
448
|
+
// documents, not executable data. They may terminate only when the user
|
|
449
|
+
// explicitly names the object in a definition request ("what is Revenue
|
|
450
|
+
// Health?"). A broad lexical match must never turn an analytical question
|
|
451
|
+
// into a no-data Certified answer merely because it starts with "what is".
|
|
452
|
+
return isBusinessDefinitionQuestion(question)
|
|
453
|
+
&& objectNameInQuestion(question, artifactHit.node);
|
|
336
454
|
})();
|
|
337
455
|
if (artifactHit && businessContextTerminal) {
|
|
338
456
|
let result;
|
|
339
457
|
let executionError;
|
|
340
458
|
if (artifactHit.node.kind === 'block' && input.executeCertifiedBlock) {
|
|
341
459
|
try {
|
|
342
|
-
result = await input.executeCertifiedBlock(artifactHit.node);
|
|
460
|
+
result = await input.executeCertifiedBlock(artifactHit.node, { question });
|
|
343
461
|
result = trimResultToRequestedTopN(result, questionPlan);
|
|
344
462
|
}
|
|
345
463
|
catch (err) {
|
|
346
464
|
executionError = err instanceof Error ? err.message : String(err);
|
|
347
465
|
}
|
|
348
466
|
}
|
|
467
|
+
const missingParameters = /^I need values for:\s*(.+?)\.?$/i.exec(executionError ?? '')?.[1]
|
|
468
|
+
?.split(',')
|
|
469
|
+
.map((value) => value.trim())
|
|
470
|
+
.filter(Boolean) ?? [];
|
|
471
|
+
if (missingParameters.length > 0) {
|
|
472
|
+
const text = `The certified block "${artifactHit.node.name}" needs ${missingParameters.join(', ')} before it can run. Please provide ${missingParameters.length === 1 ? 'that value' : 'those values'}; I will reuse the same certified block.`;
|
|
473
|
+
const citations = [{
|
|
474
|
+
nodeId: artifactHit.node.nodeId,
|
|
475
|
+
kind: artifactHit.node.kind,
|
|
476
|
+
name: artifactHit.node.name,
|
|
477
|
+
gitSha: artifactHit.node.gitSha,
|
|
478
|
+
sourceTier: 'certified_artifact',
|
|
479
|
+
provenance: artifactHit.node.provenance,
|
|
480
|
+
}];
|
|
481
|
+
const analysisPlan = buildAnalysisPlan({
|
|
482
|
+
question,
|
|
483
|
+
intent: 'clarify',
|
|
484
|
+
routeReason: 'A certified block matched, but a required values-only parameter is unresolved.',
|
|
485
|
+
selectedNodes: [artifactHit.node],
|
|
486
|
+
schemaContext,
|
|
487
|
+
assumptions: [`Required parameter values: ${missingParameters.join(', ')}.`],
|
|
488
|
+
});
|
|
489
|
+
return {
|
|
490
|
+
kind: 'no_answer',
|
|
491
|
+
sourceTier: 'certified_artifact',
|
|
492
|
+
certification: 'analyst_review_required',
|
|
493
|
+
reviewStatus: 'none',
|
|
494
|
+
confidence: 0.95,
|
|
495
|
+
text,
|
|
496
|
+
answer: text,
|
|
497
|
+
block: artifactHit.node,
|
|
498
|
+
executionError,
|
|
499
|
+
sourceCertifiedBlock: artifactHit.node.name,
|
|
500
|
+
contextPackId: input.contextPack?.id,
|
|
501
|
+
citations,
|
|
502
|
+
memoryContext: input.memoryContext,
|
|
503
|
+
analysisPlan,
|
|
504
|
+
evidence: buildNoAnswerEvidence({
|
|
505
|
+
question,
|
|
506
|
+
reason: text,
|
|
507
|
+
artifactHits,
|
|
508
|
+
businessHits,
|
|
509
|
+
semanticHits,
|
|
510
|
+
manifestHits,
|
|
511
|
+
considered,
|
|
512
|
+
memoryContext: input.memoryContext ?? [],
|
|
513
|
+
analysisPlan,
|
|
514
|
+
budgetTrace: cascadeBudgetTrace(repairBudgetState),
|
|
515
|
+
}),
|
|
516
|
+
contextPack: input.contextPack,
|
|
517
|
+
considered,
|
|
518
|
+
providerUsed: provider.name,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
349
521
|
const resultShapeWarnings = result ? validateAnswerResultShape(questionPlan, result).warnings : [];
|
|
350
522
|
// When a certified block's execution was ATTEMPTED and FAILED, the answer
|
|
351
523
|
// cannot wear the certified badge — a failed run has no data to stand behind.
|
|
@@ -429,8 +601,6 @@ async function runAnswerLoop(input) {
|
|
|
429
601
|
// a governed metric can answer. Match by name + synonyms + measure family + hybrid
|
|
430
602
|
// rank over the FTS semantic hits, then ALL metric KG nodes (revenue ⇄
|
|
431
603
|
// cumulative_revenue). Certified-first is still preserved (checked above).
|
|
432
|
-
const semanticMetricNodes = collectMetricCandidates(semanticHits, considered, kg);
|
|
433
|
-
let semanticMetricMatch = await matchSemanticMetric(question, semanticMetricNodes).catch(() => null);
|
|
434
604
|
const clarifyBeforeGeneration = shouldClarifyBeforeGeneration({
|
|
435
605
|
intent,
|
|
436
606
|
routeDecision: catalogRoute,
|
|
@@ -506,10 +676,28 @@ async function runAnswerLoop(input) {
|
|
|
506
676
|
const matchedMetricHit = semanticMetricMatch
|
|
507
677
|
? [{ node: semanticMetricMatch.metric, score: Math.max(semanticMetricMatch.score, CERTIFIED_HIT_THRESHOLD) }]
|
|
508
678
|
: [];
|
|
679
|
+
// Reserve prompt slots per source instead of appending semantic hits LAST and
|
|
680
|
+
// truncating the whole list to 14 — the old behavior dropped semantic-model hits
|
|
681
|
+
// out of the generation prompt entirely whenever artifact/business hits filled
|
|
682
|
+
// the budget first, so even a question a semantic metric could answer never saw
|
|
683
|
+
// the metric. Each group gets a guaranteed minimum; leftover budget is filled
|
|
684
|
+
// round-robin. See interleaveContextHits.
|
|
509
685
|
const contextHits = activeTier === 'semantic_layer'
|
|
510
|
-
? [
|
|
511
|
-
|
|
512
|
-
|
|
686
|
+
? interleaveContextHits([
|
|
687
|
+
{ hits: matchedMetricHit, reserve: matchedMetricHit.length },
|
|
688
|
+
{ hits: semanticHits, reserve: 5 },
|
|
689
|
+
{ hits: trustedArtifactContext, reserve: 3 },
|
|
690
|
+
{ hits: reviewRequiredArtifactHits, reserve: 2 },
|
|
691
|
+
{ hits: businessHits.slice(0, 4), reserve: 2 },
|
|
692
|
+
{ hits: manifestHits, reserve: 2 },
|
|
693
|
+
], 14)
|
|
694
|
+
: interleaveContextHits([
|
|
695
|
+
{ hits: trustedArtifactContext, reserve: 5 },
|
|
696
|
+
{ hits: reviewRequiredArtifactHits, reserve: 3 },
|
|
697
|
+
{ hits: businessHits.slice(0, 4), reserve: 3 },
|
|
698
|
+
{ hits: manifestHits, reserve: 3 },
|
|
699
|
+
], 14);
|
|
700
|
+
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));
|
|
513
701
|
const kgJoinPathHints = buildKgJoinPathHints(kg, contextNodes, questionPlan);
|
|
514
702
|
const contextBlocks = contextNodes.filter((n) => n.kind === 'block');
|
|
515
703
|
const contextBusiness = contextNodes.filter((n) => BUSINESS_CONTEXT_KINDS.includes(n.kind));
|
|
@@ -525,9 +713,32 @@ async function runAnswerLoop(input) {
|
|
|
525
713
|
const skillsPrompt = buildSkillsPrompt(selectedSkills, userId ?? null);
|
|
526
714
|
if (skillsPrompt)
|
|
527
715
|
messages.push({ role: 'system', content: skillsPrompt });
|
|
716
|
+
const analyticalPlan = input.manifest
|
|
717
|
+
? planAnalyticalPath(input.manifest, {
|
|
718
|
+
entityIds: inferAnalyticalEntityIds(question, contextNodes, input.manifest),
|
|
719
|
+
ownerDomain: input.domainContext?.activeDomain ?? input.domain,
|
|
720
|
+
purpose: input.domainContext?.purpose,
|
|
721
|
+
domainContext: input.domainContext,
|
|
722
|
+
})
|
|
723
|
+
: undefined;
|
|
724
|
+
const analyticalPlanPrompt = renderAnalyticalPlanPrompt(analyticalPlan);
|
|
725
|
+
if (analyticalPlanPrompt)
|
|
726
|
+
messages.push({ role: 'system', content: analyticalPlanPrompt });
|
|
727
|
+
if (questionDomainScope.length > 0) {
|
|
728
|
+
messages.push({
|
|
729
|
+
role: 'system',
|
|
730
|
+
content: [
|
|
731
|
+
'QUESTION DOMAIN BOUNDARY (authoritative for this generation):',
|
|
732
|
+
`The question is directly grounded in: ${questionDomainScope.join(', ')}.`,
|
|
733
|
+
'Use only relations and business context from those domains plus unscoped runtime/dbt relations supplied below.',
|
|
734
|
+
'Do not introduce, search for, or join another domain (including cross-domain acquisition/attribution paths) unless the user explicitly asks for that business concept or supplies a domain/purpose.',
|
|
735
|
+
'If the required relation is not modeled in this domain, prefer the bounded DBT-grounded exploratory candidate over an unrelated cross-domain relationship.',
|
|
736
|
+
].join('\n'),
|
|
737
|
+
});
|
|
738
|
+
}
|
|
528
739
|
messages.push({
|
|
529
740
|
role: 'system',
|
|
530
|
-
content: renderContextPrompt(contextBlocks, contextBusiness, contextOther, activeTier, input.memoryContext ?? [], input.extraContext, input.followUp, schemaContext, intent,
|
|
741
|
+
content: renderContextPrompt(contextBlocks, contextBusiness, contextOther, activeTier, input.memoryContext ?? [], input.extraContext, input.followUp, schemaContext, intent, promptContextPack, input.conversationSnapshot, kgJoinPathHints, promptBudget, input.semanticDriver),
|
|
531
742
|
});
|
|
532
743
|
messages.push({ role: 'user', content: question });
|
|
533
744
|
// ── Tier 2: semantic-layer metrics + dimensions (governed hierarchy) ──────
|
|
@@ -611,14 +822,55 @@ async function runAnswerLoop(input) {
|
|
|
611
822
|
contextPack: input.contextPack,
|
|
612
823
|
schemaContext,
|
|
613
824
|
});
|
|
825
|
+
// W2.2 — certified-block adaptation lane. When a certified block is context-only
|
|
826
|
+
// ONLY because the question adds exactly one filter whose value maps to a column
|
|
827
|
+
// the block already outputs, adapt the certified SQL (wrap + filter its result)
|
|
828
|
+
// instead of regenerating from scratch. It executes through the governed path
|
|
829
|
+
// (pre-validated, since the wrapper only restricts an already-certified result)
|
|
830
|
+
// and is labeled BELOW certified. Falls through to generation on any miss.
|
|
831
|
+
let certifiedAdaptation;
|
|
832
|
+
if (!semanticBridgeAnswer && !metricFirst && input.executeGeneratedSql) {
|
|
833
|
+
const fit = input.contextPack?.routeDecision?.blockFit;
|
|
834
|
+
const sourceBlock = input.contextPack?.allowedSqlContext?.sourceBlockSql?.[0];
|
|
835
|
+
if (fit && sourceBlock?.sql) {
|
|
836
|
+
const shape = extractSimpleSelectShape(sourceBlock.sql);
|
|
837
|
+
const blockOutputs = shape
|
|
838
|
+
? shape.selectExpressions.map((expression) => selectExpressionOutputName(expression)).filter((name) => Boolean(name))
|
|
839
|
+
: [];
|
|
840
|
+
certifiedAdaptation = planCertifiedAdaptation({
|
|
841
|
+
blockFit: fit,
|
|
842
|
+
certifiedSql: sourceBlock.sql,
|
|
843
|
+
blockName: sourceBlock.name,
|
|
844
|
+
blockOutputs,
|
|
845
|
+
resolveFilterColumn: (value) => resolveFilterValueColumns(value, schemaContext),
|
|
846
|
+
}) ?? undefined;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
614
849
|
let proposed = '';
|
|
615
850
|
let parsed;
|
|
616
851
|
const proposalToolCalls = [...semanticBridgeToolCalls];
|
|
852
|
+
// Stage-B toolset: the host's warehouse/validation tools PLUS the answer loop's
|
|
853
|
+
// own governed semantic tools (search_semantic_layer, compile_semantic_query,
|
|
854
|
+
// scan_manifest), so tool-driven generation can compile governed SQL itself and
|
|
855
|
+
// grep the live graph — across every provider (native or text-protocol). Every
|
|
856
|
+
// governed compile is recorded so the answer can be labeled governed, not
|
|
857
|
+
// hand-written, downstream (deriveAgenticTrust).
|
|
858
|
+
const compiledSemanticRecords = [];
|
|
859
|
+
const stageBTools = [
|
|
860
|
+
...(input.answerLoopTools ?? []),
|
|
861
|
+
...buildSemanticStageTools({
|
|
862
|
+
semanticLayer: input.semanticLayer,
|
|
863
|
+
kg,
|
|
864
|
+
driver: input.semanticDriver,
|
|
865
|
+
tableMapping: input.semanticTableMapping,
|
|
866
|
+
onCompiled: (record) => compiledSemanticRecords.push(record),
|
|
867
|
+
}),
|
|
868
|
+
];
|
|
617
869
|
if (semanticBridgeAnswer) {
|
|
618
870
|
governedMetricAnswer = true;
|
|
619
871
|
parsed = {
|
|
620
872
|
sql: semanticBridgeAnswer.sql,
|
|
621
|
-
text: `Answered from governed semantic metric${semanticBridgeAnswer.metrics.length === 1 ? '' : 's'} ${semanticBridgeAnswer.metrics.join(', ')}${semanticBridgeAnswer.dimensions.length > 0 ? ` by ${semanticBridgeAnswer.dimensions.join(', ')}` : ''}.
|
|
873
|
+
text: `Answered from governed semantic metric${semanticBridgeAnswer.metrics.length === 1 ? '' : 's'} ${semanticBridgeAnswer.metrics.join(', ')}${semanticBridgeAnswer.dimensions.length > 0 ? ` by ${semanticBridgeAnswer.dimensions.join(', ')}` : ''}. The semantic compiler owns this query; saving it as a reusable certified block still requires review.`,
|
|
622
874
|
viz: semanticBridgeAnswer.dimensions.length === 0 && !semanticBridgeAnswer.timeDimension ? 'single_value' : undefined,
|
|
623
875
|
};
|
|
624
876
|
}
|
|
@@ -627,16 +879,39 @@ async function runAnswerLoop(input) {
|
|
|
627
879
|
governedMetricAnswer = true;
|
|
628
880
|
parsed = {
|
|
629
881
|
sql: metricFirst.sql,
|
|
630
|
-
text: `Answered from the governed metric ${metricFirst.metric.name}${metricFirst.dimensions.length > 0 ? ` by ${metricFirst.dimensions.join(', ')}` : ''}.
|
|
882
|
+
text: `Answered from the governed metric ${metricFirst.metric.name}${metricFirst.dimensions.length > 0 ? ` by ${metricFirst.dimensions.join(', ')}` : ''}. The semantic definition owns the calculation; reusable block certification remains a separate review.`,
|
|
631
883
|
viz: metricFirst.dimensions.length === 0 ? 'single_value' : undefined,
|
|
632
884
|
};
|
|
633
885
|
}
|
|
886
|
+
else if (certifiedAdaptation) {
|
|
887
|
+
// Pre-validated governed path (the wrapper only restricts a certified result).
|
|
888
|
+
governedMetricAnswer = true;
|
|
889
|
+
parsed = {
|
|
890
|
+
sql: certifiedAdaptation.sql,
|
|
891
|
+
text: certifiedAdaptation.provenance,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
634
894
|
else {
|
|
895
|
+
// Tier 2.5 — METRIC-ANCHORED generation. A governed metric matched this question,
|
|
896
|
+
// but its semantic definition couldn't compose the exact requested shape (typically
|
|
897
|
+
// the breakdown needs a join the semantic layer doesn't own — e.g. a location-grain
|
|
898
|
+
// tax metric asked for "by product"). Rather than throw the metric away and let the
|
|
899
|
+
// model reinvent the aggregation from scratch, inject the metric's CERTIFIED
|
|
900
|
+
// definition as a required building block, so the generated SQL reuses the trusted
|
|
901
|
+
// measure and only generates the join/grouping around it. Keeps the number
|
|
902
|
+
// consistent with the governed metric. Degrades to plain generation when the metric
|
|
903
|
+
// has no resolvable definition. The grain ledger + validation still gate the output.
|
|
904
|
+
const metricAnchor = semanticMetricMatch && input.semanticLayer
|
|
905
|
+
? resolveGovernedMetricDefinition(semanticMetricMatch.metric, semanticMetricNodes, input.semanticLayer)
|
|
906
|
+
: undefined;
|
|
907
|
+
const generationMessages = metricAnchor
|
|
908
|
+
? [...messages, { role: 'system', content: metricAnchorInstruction(metricAnchor.metric.name, metricAnchor.def) }]
|
|
909
|
+
: messages;
|
|
635
910
|
try {
|
|
636
911
|
proposed = await generateProposalWithOptionalTools({
|
|
637
912
|
provider,
|
|
638
|
-
messages,
|
|
639
|
-
tools:
|
|
913
|
+
messages: generationMessages,
|
|
914
|
+
tools: stageBTools,
|
|
640
915
|
questionPlan,
|
|
641
916
|
intent,
|
|
642
917
|
signal: input.signal,
|
|
@@ -676,6 +951,35 @@ async function runAnswerLoop(input) {
|
|
|
676
951
|
};
|
|
677
952
|
}
|
|
678
953
|
parsed = parseProposal(proposed);
|
|
954
|
+
// Surface the tier choice (WS3): when generation was anchored on a governed
|
|
955
|
+
// metric, say so, so the answer reads as "reused the certified measure" rather
|
|
956
|
+
// than an opaque hand-written query.
|
|
957
|
+
if (metricAnchor && parsed.sql) {
|
|
958
|
+
const anchorNote = `Computed using the governed metric ${metricAnchor.metric.name}'s certified definition, joined to add the requested breakdown. Review-required (not itself certified).`;
|
|
959
|
+
parsed = { ...parsed, text: parsed.text ? `${parsed.text}\n\n${anchorNote}` : anchorNote };
|
|
960
|
+
}
|
|
961
|
+
// Stage-B governed promotion: when the model drove compile_semantic_query and
|
|
962
|
+
// adopted its output VERBATIM (deriveAgenticTrust — exact match only), the SQL
|
|
963
|
+
// is governed semantic SQL the compiler owns and already validated, not
|
|
964
|
+
// hand-written. Treat it like the deterministic governed path: skip the
|
|
965
|
+
// hallucination guard (governedMetricAnswer) and note the provenance. Any edit
|
|
966
|
+
// to the compiled SQL falls back to generated/review-required by construction.
|
|
967
|
+
if (!governedMetricAnswer && parsed.sql) {
|
|
968
|
+
const trust = deriveAgenticTrust(parsed.sql, compiledSemanticRecords);
|
|
969
|
+
if (trust.tier === 'semantic_metric' && trust.compiled) {
|
|
970
|
+
governedMetricAnswer = true;
|
|
971
|
+
const compiled = trust.compiled;
|
|
972
|
+
const governedNote = `Answered from governed semantic metric${compiled.metrics.length === 1 ? '' : 's'} ${compiled.metrics.join(', ')}${compiled.dimensions.length > 0 ? ` by ${compiled.dimensions.join(', ')}` : ''} (compiled via the semantic layer). Reusable block certification remains a separate review.`;
|
|
973
|
+
parsed = { ...parsed, text: parsed.text ? `${parsed.text}\n\n${governedNote}` : governedNote };
|
|
974
|
+
proposalToolCalls.push({
|
|
975
|
+
name: 'compile_semantic_query',
|
|
976
|
+
status: 'checked',
|
|
977
|
+
inputSummary: `metrics: ${compiled.metrics.join(', ')}${compiled.dimensions.length ? `; by ${compiled.dimensions.join(', ')}` : ''}`,
|
|
978
|
+
outputSummary: 'Model-selected semantic members compiled to governed SQL',
|
|
979
|
+
order: proposalToolCalls.length + 1,
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
}
|
|
679
983
|
}
|
|
680
984
|
let deepCandidateResult;
|
|
681
985
|
let deepCandidateExecutionError;
|
|
@@ -692,6 +996,7 @@ async function runAnswerLoop(input) {
|
|
|
692
996
|
executeGeneratedSql: input.executeGeneratedSql,
|
|
693
997
|
signal: input.signal,
|
|
694
998
|
reasoningEffort: input.reasoningEffort,
|
|
999
|
+
maxAlternatives: deepAlternativeCountForQuestion(questionPlan, intent),
|
|
695
1000
|
});
|
|
696
1001
|
if (selection.selected) {
|
|
697
1002
|
proposed = selection.selected.raw;
|
|
@@ -725,7 +1030,7 @@ async function runAnswerLoop(input) {
|
|
|
725
1030
|
parsed = {
|
|
726
1031
|
sql: resolved.sql,
|
|
727
1032
|
text: parsed.text ||
|
|
728
|
-
`Answered from the governed metric ${resolved.metric.name}.
|
|
1033
|
+
`Answered from the governed metric ${resolved.metric.name}. The semantic definition owns the calculation; reusable block certification remains a separate review.`,
|
|
729
1034
|
viz: parsed.viz ?? 'single_value',
|
|
730
1035
|
};
|
|
731
1036
|
}
|
|
@@ -746,12 +1051,12 @@ async function runAnswerLoop(input) {
|
|
|
746
1051
|
|| (input.contextPack?.allowedSqlContext?.relations.length ?? 0) > 0
|
|
747
1052
|
|| (input.contextPack?.allowedSqlContext?.sourceBlockSql.length ?? 0) > 0
|
|
748
1053
|
|| contextBlocks.length > 0;
|
|
749
|
-
if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext) {
|
|
1054
|
+
if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext && analyticalPlan?.safe !== false) {
|
|
750
1055
|
try {
|
|
751
1056
|
proposed = await generateProposalWithOptionalTools({
|
|
752
1057
|
provider,
|
|
753
1058
|
messages: [...messages, { role: 'system', content: FORCE_JOIN_INSTRUCTION }],
|
|
754
|
-
tools:
|
|
1059
|
+
tools: stageBTools,
|
|
755
1060
|
questionPlan,
|
|
756
1061
|
intent,
|
|
757
1062
|
signal: input.signal,
|
|
@@ -775,9 +1080,11 @@ async function runAnswerLoop(input) {
|
|
|
775
1080
|
// every run and every surface — instead of passing through the model's varying
|
|
776
1081
|
// text. A genuinely context-less ask keeps the plain honest message.
|
|
777
1082
|
const declinedDespiteContext = wantsGeneratedData && hasGeneratableContext;
|
|
778
|
-
const text =
|
|
779
|
-
? '
|
|
780
|
-
:
|
|
1083
|
+
const text = analyticalPlan?.safe === false
|
|
1084
|
+
? analyticalPlan.message ?? 'DQL could not prove a safe analytical relationship path.'
|
|
1085
|
+
: declinedDespiteContext
|
|
1086
|
+
? 'I could not compose a governed query for this from the available tables and metrics. This usually needs a clearer join path or an explicit metric and grouping — name the specific measure and how to break it down, and I can generate a review-required draft.'
|
|
1087
|
+
: parsed.text || 'No answer (the model declined to propose SQL).';
|
|
781
1088
|
return {
|
|
782
1089
|
kind: 'no_answer',
|
|
783
1090
|
sourceTier: 'no_answer',
|
|
@@ -785,9 +1092,13 @@ async function runAnswerLoop(input) {
|
|
|
785
1092
|
reviewStatus: 'none',
|
|
786
1093
|
confidence: 0.1,
|
|
787
1094
|
text,
|
|
788
|
-
refusalCode:
|
|
1095
|
+
refusalCode: analyticalPlan?.safe === false
|
|
1096
|
+
? refusalCodeForAnalyticalPolicy(analyticalPlan.code)
|
|
1097
|
+
: 'model_declined',
|
|
789
1098
|
refusalDetails: {
|
|
790
|
-
code:
|
|
1099
|
+
code: analyticalPlan?.safe === false
|
|
1100
|
+
? analyticalPlan.code ?? 'unsafe_relationship'
|
|
1101
|
+
: 'model_declined',
|
|
791
1102
|
message: text,
|
|
792
1103
|
},
|
|
793
1104
|
answer: text,
|
|
@@ -823,20 +1134,27 @@ async function runAnswerLoop(input) {
|
|
|
823
1134
|
&& questionPlan.requestedShape.filters.length === 0
|
|
824
1135
|
&& !questionPlan.requestedShape.topN;
|
|
825
1136
|
let contextValidation;
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1137
|
+
const initialValidation = contextLedger.validateSql(parsed.sql, {
|
|
1138
|
+
question,
|
|
1139
|
+
intent,
|
|
1140
|
+
filterValues: input.followUp?.filters,
|
|
1141
|
+
trustedFilterValues: trustedFollowUpFilterValues(input.followUp),
|
|
1142
|
+
});
|
|
1143
|
+
contextValidation = initialValidation.ok
|
|
1144
|
+
? { ok: true, warnings: initialValidation.warnings }
|
|
1145
|
+
: {
|
|
1146
|
+
ok: false,
|
|
1147
|
+
code: initialValidation.code,
|
|
1148
|
+
error: initialValidation.error,
|
|
1149
|
+
warnings: initialValidation.warnings,
|
|
1150
|
+
offending: initialValidation.offending,
|
|
1151
|
+
};
|
|
1152
|
+
// Semantic compilation owns metric meaning, but a later dimensional join can
|
|
1153
|
+
// still make a compiled bare expression ambiguous at the warehouse binder.
|
|
1154
|
+
// Revoke governed trust for an invalid composition so the bounded repair lane
|
|
1155
|
+
// can qualify it and keep the repaired SQL review-required.
|
|
1156
|
+
if (governedMetricAnswer && !contextValidation.ok)
|
|
1157
|
+
governedMetricAnswer = false;
|
|
840
1158
|
// Spec 17, part C — semantic-metric route recovery. A metric question is often
|
|
841
1159
|
// catalog-routed to clarify, leaving a thin contextPack that rejects otherwise-valid
|
|
842
1160
|
// SQL. Rather than refuse, recover in two steps:
|
|
@@ -959,6 +1277,21 @@ async function runAnswerLoop(input) {
|
|
|
959
1277
|
// Repair is best-effort — the refusal below stays the honest fallback.
|
|
960
1278
|
}
|
|
961
1279
|
}
|
|
1280
|
+
// W1.3 — deterministic, warn-only fan-out check. When the generated SQL aggregates
|
|
1281
|
+
// an additive measure across a one-to-many join (grain ledger knows the keys), the
|
|
1282
|
+
// number can silently double-count. Surface a caution so it is reviewed. This never
|
|
1283
|
+
// blocks: it only appends to warnings, and it is conservative (no key data ⇒ no flag).
|
|
1284
|
+
if (contextValidation.ok && parsed.sql && input.contextPack?.objects?.length) {
|
|
1285
|
+
try {
|
|
1286
|
+
const fanoutWarnings = fanoutWarningsForSql(parsed.sql, input.contextPack.objects);
|
|
1287
|
+
if (fanoutWarnings.length > 0) {
|
|
1288
|
+
contextValidation = { ok: true, warnings: [...contextValidation.warnings, ...fanoutWarnings] };
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
catch {
|
|
1292
|
+
// Fan-out detection is advisory; any failure must not affect the answer.
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
962
1295
|
if (!contextValidation.ok) {
|
|
963
1296
|
const text = `I could not safely prepare this review-required DQL artifact from the inspected context. The SQL preview failed validation: ${contextValidation.error}`;
|
|
964
1297
|
const analysisPlan = buildAnalysisPlan({
|
|
@@ -1015,6 +1348,70 @@ async function runAnswerLoop(input) {
|
|
|
1015
1348
|
providerUsed: provider.name,
|
|
1016
1349
|
};
|
|
1017
1350
|
}
|
|
1351
|
+
const dbtFirstJoinSafety = input.manifest
|
|
1352
|
+
? evaluateDbtFirstGeneratedSql(parsed.sql, input.manifest, input.domainContext?.purpose, input.domainContext)
|
|
1353
|
+
: undefined;
|
|
1354
|
+
if (dbtFirstJoinSafety && !dbtFirstJoinSafety.safe) {
|
|
1355
|
+
const text = dbtFirstJoinSafety.message
|
|
1356
|
+
?? 'DQL could not prove this generated join from certified analytical relationships.';
|
|
1357
|
+
const exploratoryCandidate = exploratoryCandidateFromDbtFirstGuard(parsed.sql, dbtFirstJoinSafety);
|
|
1358
|
+
const analysisPlan = buildAnalysisPlan({
|
|
1359
|
+
question,
|
|
1360
|
+
intent,
|
|
1361
|
+
routeReason: exploratoryCandidate
|
|
1362
|
+
? 'Governed relationship coverage is missing, so DQL prepared a bounded DBT-grounded exploratory candidate for host validation.'
|
|
1363
|
+
: 'The generated SQL did not pass the governed relationship policy.',
|
|
1364
|
+
selectedNodes: contextNodes,
|
|
1365
|
+
schemaContext,
|
|
1366
|
+
sql: parsed.sql,
|
|
1367
|
+
suggestedViz: parsed.viz ?? 'table',
|
|
1368
|
+
assumptions: [
|
|
1369
|
+
...contextValidation.warnings,
|
|
1370
|
+
'DBT metadata is grounding evidence, not certified relationship proof.',
|
|
1371
|
+
],
|
|
1372
|
+
});
|
|
1373
|
+
return {
|
|
1374
|
+
kind: 'no_answer',
|
|
1375
|
+
sourceTier: 'no_answer',
|
|
1376
|
+
certification: 'analyst_review_required',
|
|
1377
|
+
reviewStatus: 'none',
|
|
1378
|
+
confidence: 0.1,
|
|
1379
|
+
text,
|
|
1380
|
+
answer: text,
|
|
1381
|
+
proposedSql: parsed.sql,
|
|
1382
|
+
sql: parsed.sql,
|
|
1383
|
+
...(exploratoryCandidate ? { exploratoryCandidate } : {}),
|
|
1384
|
+
refusalCode: refusalCodeForAnalyticalPolicy(dbtFirstJoinSafety.code, Boolean(exploratoryCandidate)),
|
|
1385
|
+
refusalDetails: {
|
|
1386
|
+
code: dbtFirstJoinSafety.code ?? 'unsafe_relationship',
|
|
1387
|
+
message: text,
|
|
1388
|
+
},
|
|
1389
|
+
validationWarnings: [
|
|
1390
|
+
...contextValidation.warnings,
|
|
1391
|
+
`DQL v3 relationship guard: ${dbtFirstJoinSafety.code ?? 'unsafe relationship'}.`,
|
|
1392
|
+
...(exploratoryCandidate
|
|
1393
|
+
? ['A bounded DBT-grounded exploratory route may validate this missing modeled relationship; governed SQL was not executed.']
|
|
1394
|
+
: []),
|
|
1395
|
+
],
|
|
1396
|
+
citations: [],
|
|
1397
|
+
memoryContext: input.memoryContext,
|
|
1398
|
+
analysisPlan,
|
|
1399
|
+
evidence: buildNoAnswerEvidence({
|
|
1400
|
+
question,
|
|
1401
|
+
reason: text,
|
|
1402
|
+
artifactHits,
|
|
1403
|
+
businessHits,
|
|
1404
|
+
semanticHits,
|
|
1405
|
+
manifestHits,
|
|
1406
|
+
considered,
|
|
1407
|
+
memoryContext: input.memoryContext ?? [],
|
|
1408
|
+
analysisPlan,
|
|
1409
|
+
}),
|
|
1410
|
+
contextPack: input.contextPack,
|
|
1411
|
+
considered,
|
|
1412
|
+
providerUsed: provider.name,
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1018
1415
|
const generatedCitations = [
|
|
1019
1416
|
...contextPackCitations(input.contextPack, 4),
|
|
1020
1417
|
...contextNodes.slice(0, 4).map((n) => ({
|
|
@@ -1239,26 +1636,32 @@ async function runAnswerLoop(input) {
|
|
|
1239
1636
|
? { ...dqlArtifact, sourcePath: dqlArtifact.sourcePath ?? draftBlock.path }
|
|
1240
1637
|
: dqlArtifact;
|
|
1241
1638
|
const sourceCertifiedBlock = followUpSourceBlock?.name ?? input.followUp?.sourceBlockName;
|
|
1242
|
-
const trustExplanation =
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1639
|
+
const trustExplanation = governedMetricAnswer
|
|
1640
|
+
? undefined
|
|
1641
|
+
: generatedTrustExplanation({
|
|
1642
|
+
followUp: input.followUp,
|
|
1643
|
+
sourceCertifiedBlock,
|
|
1644
|
+
draftBlock,
|
|
1645
|
+
});
|
|
1247
1646
|
const cleanedSummary = cleanGeneratedSummary(parsed.text);
|
|
1248
1647
|
const generatedText = [partialShapeWarning, trustExplanation, cleanedSummary]
|
|
1249
1648
|
.filter(Boolean)
|
|
1250
1649
|
.join('\n\n');
|
|
1251
|
-
|
|
1650
|
+
// A metric can be resolved directly from the loaded semantic layer even if
|
|
1651
|
+
// the small metadata pack happened to contain only dbt objects. The answer
|
|
1652
|
+
// is still compiler-owned semantic SQL in that case; do not accidentally
|
|
1653
|
+
// downgrade its route/trust to generated SQL because of retrieval order.
|
|
1654
|
+
const semanticMetricCertification = governedMetricAnswer
|
|
1252
1655
|
? semanticMetricMatch?.metric.certification
|
|
1253
1656
|
: undefined;
|
|
1254
1657
|
const certifiedMetricAnswer = semanticMetricCertification === 'certified' || semanticMetricCertification === 'reviewed';
|
|
1255
1658
|
return {
|
|
1256
1659
|
kind: 'uncertified',
|
|
1257
|
-
sourceTier: activeTier,
|
|
1258
|
-
certification: 'ai_generated',
|
|
1259
|
-
reviewStatus: 'draft_ready',
|
|
1660
|
+
sourceTier: governedMetricAnswer ? 'semantic_layer' : activeTier,
|
|
1661
|
+
certification: governedMetricAnswer ? 'governed' : 'ai_generated',
|
|
1662
|
+
reviewStatus: governedMetricAnswer ? 'governed' : 'draft_ready',
|
|
1260
1663
|
semanticMetricCertification,
|
|
1261
|
-
confidence: certifiedMetricAnswer ? 0.8 : governedMetricAnswer
|
|
1664
|
+
confidence: certifiedMetricAnswer ? 0.8 : governedMetricAnswer ? 0.72 : 0.55,
|
|
1262
1665
|
text: generatedText,
|
|
1263
1666
|
answer: generatedText,
|
|
1264
1667
|
proposedSql: parsed.sql,
|
|
@@ -1308,7 +1711,7 @@ async function runAnswerLoop(input) {
|
|
|
1308
1711
|
providerUsed: provider.name,
|
|
1309
1712
|
// Carry the governed metric match so the exit point can name a
|
|
1310
1713
|
// `semantic_metric` route (spec 17, part C).
|
|
1311
|
-
_semanticMetricMatch: governedMetricAnswer
|
|
1714
|
+
_semanticMetricMatch: governedMetricAnswer ? semanticMetricMatch ?? undefined : undefined,
|
|
1312
1715
|
};
|
|
1313
1716
|
}
|
|
1314
1717
|
function trimResultToRequestedTopN(result, plan) {
|
|
@@ -1353,13 +1756,35 @@ Rules:
|
|
|
1353
1756
|
separate SQL code fence when using JSON. The optional "dql" object is
|
|
1354
1757
|
metadata for the deterministic draft-DQL renderer; do not hand-write a full
|
|
1355
1758
|
DQL block.
|
|
1759
|
+
Design the SELECT for a business reader: prefer a joined/display name, label,
|
|
1760
|
+
title, or business value over raw *_id, *_uuid, *_key, or technical codes.
|
|
1761
|
+
Alias calculated outputs with clear business names. Include identifiers only
|
|
1762
|
+
when the question asks for them or no readable field exists. Give every FROM
|
|
1763
|
+
and JOIN relation an explicit short alias, and use only those exact aliases in
|
|
1764
|
+
SELECT, ON, WHERE, GROUP BY, and ORDER BY. When a query reads more than one
|
|
1765
|
+
relation, qualify every column reference with its exact relation alias; never
|
|
1766
|
+
emit a bare column name. Include every requested output. Match the requested
|
|
1767
|
+
result grain exactly: project and GROUP BY only the entities or dimensions the
|
|
1768
|
+
user asked to compare. A category/value used only to filter the result is not
|
|
1769
|
+
an additional output dimension. An entity ranking filtered to a category
|
|
1770
|
+
value must return one row per requested entity, not one row per
|
|
1771
|
+
entity-category-member pair.
|
|
1356
1772
|
4. In summary, state your QUERY PLAN first: the grain (one row per WHAT), the
|
|
1357
1773
|
measures and how they aggregate, the dimensions/filters, and the exact join
|
|
1358
1774
|
path + join keys between the grounded tables. Then make the SQL match that
|
|
1359
1775
|
plan — an explicit grain and join path prevents wrong-grain answers and
|
|
1360
|
-
fan-out (row-multiplying) joins.
|
|
1361
|
-
|
|
1362
|
-
|
|
1776
|
+
fan-out (row-multiplying) joins. Treat lifetime, cumulative, balance,
|
|
1777
|
+
snapshot, rate, ratio, average, and already-aggregated values as
|
|
1778
|
+
non-additive across lower-grain joins: pre-aggregate at their native grain,
|
|
1779
|
+
or use a grain-preserving value only when the owning entity remains in the
|
|
1780
|
+
output. Never SUM the repeated parent value at child grain. If the question
|
|
1781
|
+
requires allocating a parent value to children, ask for the allocation rule.
|
|
1782
|
+
5. Choose a visualization deliberately in the "viz" JSON field: use single_value
|
|
1783
|
+
or kpi for one aggregate, line/area only for an ordered time series, bar for a
|
|
1784
|
+
categorical comparison, grouped-bar for multiple measures by one category,
|
|
1785
|
+
scatter for two continuous measures, and table when the result is not chartable.
|
|
1786
|
+
Do not default to bar. The runtime validates this preference against returned
|
|
1787
|
+
rows before displaying it.
|
|
1363
1788
|
6. NEVER fabricate column names that are not present in the supplied schema,
|
|
1364
1789
|
dbt metadata, or certified source SQL shape context. If a requested filter
|
|
1365
1790
|
value is supplied as a matched value, prefer the table and column that
|
|
@@ -1368,15 +1793,19 @@ Rules:
|
|
|
1368
1793
|
Do NOT use dbt/Jinja macros such as {{ ref(...) }} or {{ source(...) }} in
|
|
1369
1794
|
proposed SQL. Do not emit INSERT, UPDATE, DELETE, CREATE, DROP, ALTER, COPY,
|
|
1370
1795
|
PRAGMA, SET, or multiple statements.
|
|
1371
|
-
8. If the schema is insufficient
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1796
|
+
8. If the schema is insufficient, DISCOVER before you decline: use
|
|
1797
|
+
search_metadata to find candidate relations, get_table_schema to confirm a
|
|
1798
|
+
relation's real columns and inferred join keys, and expand_context for a
|
|
1799
|
+
relation you already know by name. Only ask a clarifying question once those
|
|
1800
|
+
come up empty AND the question is genuinely ambiguous — never decline just
|
|
1801
|
+
because the initially supplied context pack didn't already include the table.
|
|
1802
|
+
A MULTI-ENTITY question ("top customers who bought the top products", "accounts
|
|
1803
|
+
with the most overdue invoices") is NOT insufficient context when the grounded
|
|
1804
|
+
tables and a join route between them are supplied (see any "Knowledge graph
|
|
1805
|
+
join routes" section) or discoverable via the tools above. In that case you
|
|
1806
|
+
MUST compose the joined SELECT that answers it directly — never refuse it or
|
|
1807
|
+
offer to show the parts as separate datasets. Reserve the clarifying question
|
|
1808
|
+
for a genuinely absent table, column, or join key.
|
|
1380
1809
|
9. Write directly to the analyst. Do not say "the user is asking", "the user
|
|
1381
1810
|
requested", "I will generate", or describe internal routing. State the
|
|
1382
1811
|
answer, the certified context used, the DQL artifact expectation, and the
|
|
@@ -1390,7 +1819,173 @@ Rules:
|
|
|
1390
1819
|
// combined dataset — show them separately" refusal into the join the user asked
|
|
1391
1820
|
// for, while still allowing an honest refusal if context is truly missing.
|
|
1392
1821
|
const FORCE_JOIN_INSTRUCTION = `Your previous attempt declined to produce SQL. Re-read the supplied schema, metadata, and any "Knowledge graph join routes": this question CAN be answered by joining the grounded tables along their documented keys. Do NOT refuse, and do NOT suggest showing the datasets separately — compose ONE read-only SELECT/WITH that joins the relevant tables to answer it directly, following the JSON contract from rule 3. State the grain and the exact join path in the summary. Only if a required table, column, or join key is truly absent from the supplied context may you still ask a clarifying question.`;
|
|
1393
|
-
|
|
1822
|
+
/**
|
|
1823
|
+
* Produces the prompt-facing subset of a broad local context pack. Global
|
|
1824
|
+
* records remain available, while a record explicitly owned by another domain
|
|
1825
|
+
* cannot steer an otherwise single-domain question into its relationship path.
|
|
1826
|
+
* This is not a security boundary; manifest/domain-context validation still
|
|
1827
|
+
* happens after generation and at execution.
|
|
1828
|
+
*/
|
|
1829
|
+
function scopeContextPackToQuestionDomains(contextPack, domains, manifest) {
|
|
1830
|
+
if (!contextPack || domains.length === 0)
|
|
1831
|
+
return contextPack;
|
|
1832
|
+
const allowedDomains = new Set(domains);
|
|
1833
|
+
const relationDomains = manifestRelationDomains(manifest);
|
|
1834
|
+
const objectDomain = (object) => object.domain ?? inferredManifestDomainForMetadataObject(object, relationDomains);
|
|
1835
|
+
const objects = contextPack.objects.filter((object) => {
|
|
1836
|
+
const domain = objectDomain(object);
|
|
1837
|
+
return !domain || allowedDomains.has(domain);
|
|
1838
|
+
});
|
|
1839
|
+
const objectKeys = new Set(objects.map((object) => object.objectKey));
|
|
1840
|
+
const relations = contextPack.allowedSqlContext.relations.filter((relation) => (!relation.objectKey || objectKeys.has(relation.objectKey))
|
|
1841
|
+
&& (!relationDomains.get(normalizeRelationKey(relation.relation))
|
|
1842
|
+
|| allowedDomains.has(relationDomains.get(normalizeRelationKey(relation.relation)))));
|
|
1843
|
+
const relationKeys = new Set(relations.map((relation) => normalizeRelationKey(relation.relation)));
|
|
1844
|
+
const selectedRelations = contextPack.retrievalDiagnostics.selectedRelations?.filter((relation) => relationKeys.has(normalizeRelationKey(relation.relation)));
|
|
1845
|
+
const selectedJoinPaths = contextPack.retrievalDiagnostics.selectedJoinPaths?.filter((path) => relationKeys.has(normalizeRelationKey(path.leftRelation))
|
|
1846
|
+
&& relationKeys.has(normalizeRelationKey(path.rightRelation)));
|
|
1847
|
+
return {
|
|
1848
|
+
...contextPack,
|
|
1849
|
+
focusObjectKey: contextPack.focusObjectKey && objectKeys.has(contextPack.focusObjectKey)
|
|
1850
|
+
? contextPack.focusObjectKey
|
|
1851
|
+
: objects[0]?.objectKey ?? null,
|
|
1852
|
+
objects,
|
|
1853
|
+
skills: contextPack.skills.filter((skill) => !skill.domain || allowedDomains.has(skill.domain)),
|
|
1854
|
+
edges: contextPack.edges.filter((edge) => objectKeys.has(edge.fromKey) && objectKeys.has(edge.toKey)),
|
|
1855
|
+
citations: contextPack.citations.filter((citation) => objectKeys.has(citation.objectKey)),
|
|
1856
|
+
evidenceSummaries: contextPack.evidenceSummaries.filter((summary) => !summary.objectKey || objectKeys.has(summary.objectKey)),
|
|
1857
|
+
evidenceRoles: contextPack.evidenceRoles.filter((role) => objectKeys.has(role.objectKey)),
|
|
1858
|
+
allowedSqlContext: {
|
|
1859
|
+
relations,
|
|
1860
|
+
sourceBlockSql: contextPack.allowedSqlContext.sourceBlockSql.filter((source) => objectKeys.has(source.objectKey)),
|
|
1861
|
+
},
|
|
1862
|
+
retrievalDiagnostics: {
|
|
1863
|
+
...contextPack.retrievalDiagnostics,
|
|
1864
|
+
selectedObjects: objects.length,
|
|
1865
|
+
selectedEvidence: contextPack.retrievalDiagnostics.selectedEvidence.filter((evidence) => objectKeys.has(evidence.objectKey)),
|
|
1866
|
+
selectedRelations,
|
|
1867
|
+
selectedJoinPaths,
|
|
1868
|
+
schemaShapeCandidates: contextPack.retrievalDiagnostics.schemaShapeCandidates?.filter((candidate) => objectKeys.has(candidate.objectKey)),
|
|
1869
|
+
certifiedCandidateFits: contextPack.retrievalDiagnostics.certifiedCandidateFits.filter((candidate) => objectKeys.has(candidate.objectKey)),
|
|
1870
|
+
},
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
function manifestRelationDomains(manifest) {
|
|
1874
|
+
const domains = new Map();
|
|
1875
|
+
if (!manifest?.modeling || !manifest.dbtProvenance)
|
|
1876
|
+
return domains;
|
|
1877
|
+
for (const entity of Object.values(manifest.modeling.entities)) {
|
|
1878
|
+
const relation = manifest.dbtProvenance.nodes[entity.dbtUniqueId]?.relation;
|
|
1879
|
+
if (relation && entity.domain)
|
|
1880
|
+
domains.set(normalizeRelationKey(relation), entity.domain);
|
|
1881
|
+
}
|
|
1882
|
+
return domains;
|
|
1883
|
+
}
|
|
1884
|
+
function inferredManifestDomainForMetadataObject(object, relationDomains) {
|
|
1885
|
+
const payload = object.payload ?? {};
|
|
1886
|
+
const relation = typeof payload.relation === 'string' ? payload.relation : undefined;
|
|
1887
|
+
if (relation) {
|
|
1888
|
+
const direct = relationDomains.get(normalizeRelationKey(relation));
|
|
1889
|
+
if (direct)
|
|
1890
|
+
return direct;
|
|
1891
|
+
}
|
|
1892
|
+
const haystack = `${object.objectKey} ${object.name}`.toLowerCase();
|
|
1893
|
+
for (const [relation, domain] of relationDomains) {
|
|
1894
|
+
const model = relation.split('.').at(-1);
|
|
1895
|
+
if (model && haystack.includes(model))
|
|
1896
|
+
return domain;
|
|
1897
|
+
}
|
|
1898
|
+
return undefined;
|
|
1899
|
+
}
|
|
1900
|
+
/** Removes runtime tables that were explicitly present in, then excluded from, a broad context pack. */
|
|
1901
|
+
function schemaContextWithinQuestionScope(schemaContext, fullContextPack, scopedContextPack) {
|
|
1902
|
+
if (!fullContextPack || fullContextPack === scopedContextPack)
|
|
1903
|
+
return schemaContext;
|
|
1904
|
+
const fullRelations = new Set(fullContextPack.allowedSqlContext.relations.map((relation) => normalizeRelationKey(relation.relation)));
|
|
1905
|
+
const scopedRelations = new Set((scopedContextPack?.allowedSqlContext.relations ?? []).map((relation) => normalizeRelationKey(relation.relation)));
|
|
1906
|
+
return schemaContext.filter((table) => {
|
|
1907
|
+
const key = normalizeRelationKey(table.relation);
|
|
1908
|
+
return !fullRelations.has(key) || scopedRelations.has(key);
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
1911
|
+
export function inferAnalyticalEntityIds(question, contextNodes, manifest) {
|
|
1912
|
+
if (manifest.manifestVersion !== 3 || !manifest.modeling || !manifest.dbtProvenance)
|
|
1913
|
+
return [];
|
|
1914
|
+
const normalizedQuestion = question.toLowerCase().replace(/[^a-z0-9]+/g, ' ');
|
|
1915
|
+
const contextIds = new Set(contextNodes.map((node) => node.nodeId.toLowerCase()));
|
|
1916
|
+
const entityIdentifierTokens = new Set(Object.values(manifest.modeling.entities).flatMap((entity) => entityIdentifierTokensForMatching(entity).map((token) => token.toLowerCase())));
|
|
1917
|
+
const scored = Object.entries(manifest.modeling.entities).map(([key, entity]) => {
|
|
1918
|
+
const dbt = manifest.dbtProvenance?.nodes[entity.dbtUniqueId];
|
|
1919
|
+
const entityTokens = entityIdentifierTokensForMatching(entity);
|
|
1920
|
+
// A backing dbt model may contain another entity's name (for example
|
|
1921
|
+
// `dim_customer_acquisition`). Its `customer` token must not turn a plain
|
|
1922
|
+
// customer question into an acquisition request. Only model tokens that are
|
|
1923
|
+
// unique to this entity are supplemental lexical evidence.
|
|
1924
|
+
const modelTokens = (dbt?.name ?? '')
|
|
1925
|
+
.toLowerCase()
|
|
1926
|
+
.replace(/^(fct|dim|stg)_/, '')
|
|
1927
|
+
.split(/[^a-z0-9]+/)
|
|
1928
|
+
.filter((token) => token.length > 2 && !entityIdentifierTokens.has(token));
|
|
1929
|
+
const direct = entityTokens.some((token) => normalizedQuestion.includes(token))
|
|
1930
|
+
|| modelTokens.some((token) => normalizedQuestion.includes(token));
|
|
1931
|
+
const inContext = contextIds.has(`entity:${entity.qualifiedId ?? entity.id}`.toLowerCase())
|
|
1932
|
+
|| contextIds.has(`dbt_model:${entity.dbtUniqueId}`.toLowerCase())
|
|
1933
|
+
|| contextNodes.some((node) => node.kind === 'dbt_model' && node.name === dbt?.name);
|
|
1934
|
+
return { id: key, direct, inContext };
|
|
1935
|
+
});
|
|
1936
|
+
// AGT-004: context retrieval is useful for completing a clearly named model,
|
|
1937
|
+
// but it must not manufacture a second entity merely because an unrelated
|
|
1938
|
+
// cross-domain block ranked in the context window. That was how a Commerce
|
|
1939
|
+
// product question reached Growth's attribution path. Prefer lexical evidence
|
|
1940
|
+
// whenever the question names at least one modeled entity; use context-only
|
|
1941
|
+
// inference solely as a fallback for genuinely indirect wording.
|
|
1942
|
+
const direct = scored.filter((entry) => entry.direct);
|
|
1943
|
+
const candidates = direct.length > 0 ? direct : scored.filter((entry) => entry.inContext);
|
|
1944
|
+
return candidates
|
|
1945
|
+
.sort((a, b) => Number(b.direct) - Number(a.direct) || a.id.localeCompare(b.id))
|
|
1946
|
+
.slice(0, 5)
|
|
1947
|
+
.map((entry) => entry.id);
|
|
1948
|
+
}
|
|
1949
|
+
function entityIdentifierTokensForMatching(entity) {
|
|
1950
|
+
return [entity.localId ?? entity.id]
|
|
1951
|
+
.flatMap((value) => value.toLowerCase().replace(/^(fct|dim|stg)_/, '').split(/[^a-z0-9]+/))
|
|
1952
|
+
.filter((token) => token.length > 2 && token !== 'entity');
|
|
1953
|
+
}
|
|
1954
|
+
function renderAnalyticalPlanPrompt(plan) {
|
|
1955
|
+
if (!plan || plan.entities.length < 2)
|
|
1956
|
+
return undefined;
|
|
1957
|
+
if (!plan.safe) {
|
|
1958
|
+
return [
|
|
1959
|
+
'DQL ANALYTICAL POLICY DECISION: BLOCKED.',
|
|
1960
|
+
plan.message ?? 'No certified analytical relationship path is available.',
|
|
1961
|
+
`Policy code: ${plan.code ?? 'unsafe_relationship'}.`,
|
|
1962
|
+
'Do not invent a join from dbt lineage or shared column names. Return a clarification or refusal that asks for the missing relationship, export/import, or attribution policy.',
|
|
1963
|
+
].join('\n');
|
|
1964
|
+
}
|
|
1965
|
+
return [
|
|
1966
|
+
'DQL CERTIFIED ANALYTICAL JOIN PLAN (authoritative):',
|
|
1967
|
+
...plan.edges.map((edge, index) => [
|
|
1968
|
+
`${index + 1}. ${edge.fromEntity} (${edge.fromRelation ?? 'bound dbt model'}) -> ${edge.toEntity} (${edge.toRelation ?? 'bound dbt model'})`,
|
|
1969
|
+
` relationship=${edge.relationshipId}; keys=${edge.keys.map((key) => `${key.from}=${key.to}`).join(', ')}; cardinality=${edge.cardinality}; fanout=${edge.fanout}`,
|
|
1970
|
+
edge.importRefs.length ? ` imports=${edge.importRefs.join(', ')}` : '',
|
|
1971
|
+
].filter(Boolean).join('\n')),
|
|
1972
|
+
'Use only these relationships and exact key pairs. dbt DAG lineage and same-named columns are not join authorization. Any different join must be refused.',
|
|
1973
|
+
].join('\n');
|
|
1974
|
+
}
|
|
1975
|
+
// Metric-anchored generation (Tier 2.5): a governed metric matched the question but
|
|
1976
|
+
// the semantic layer couldn't compose the exact shape. Reuse the metric's CERTIFIED
|
|
1977
|
+
// definition as the measure rather than reinventing it, so the number stays consistent
|
|
1978
|
+
// with the governed metric — only the join/grouping around it is generated.
|
|
1979
|
+
function metricAnchorInstruction(metricName, def) {
|
|
1980
|
+
return `A GOVERNED metric matched this question, but the semantic layer could not compose the exact requested shape on its own (the breakdown likely needs a join the metric's table does not own). You MUST compute the measure using this certified definition — do NOT redefine, rename, or approximate it:\n metric "${metricName}": ${def.expr} (defined over ${def.table})\nCompose ONE read-only SELECT/WITH using explicit aliases for every relation and include every output the user requested. Preserve the metric at its native grain before joining to a lower-grain dimension. NEVER SUM a lifetime, cumulative, balance, snapshot, rate, ratio, or already-aggregated value after a one-to-many join. If the requested result retains the metric's owning entity, expose the native-grain value (or a grain-preserving MAX after deduplication) for each entity/dimension pair; if it asks to allocate that value to the child dimension, request the missing allocation policy instead of inventing one. Join ${def.table} to other grounded tables only along documented keys. State the join path, output grain, and whether each measure is an entity-level attribute or an allocated child-level measure. Reusing the governed definition keeps the answer consistent with the certified metric; if the semantic layer cannot authorize the requested dimensional composition, keep the result exploratory/review-required rather than claiming semantic provenance.`;
|
|
1981
|
+
}
|
|
1982
|
+
function renderContextPrompt(blocks, businessContext, others, activeTier, memoryContext, extraContext, followUp, schemaContext = [], intent = 'ad_hoc_analysis', contextPack, conversationSnapshot, kgJoinPathHints = [], budget = QUICK_PROMPT_CONTEXT_BUDGET, driver) {
|
|
1983
|
+
// W1.5 — dialect conventions so the model writes warehouse-correct SQL
|
|
1984
|
+
// (quoting, row-limiting, date functions) instead of a DuckDB/Postgres default
|
|
1985
|
+
// that fails on Snowflake/BigQuery/etc. Only emitted when a driver is known.
|
|
1986
|
+
const dialectSection = driver
|
|
1987
|
+
? `\n\n## SQL dialect\n\n${describeDialectForPrompt(driver)}`
|
|
1988
|
+
: '';
|
|
1394
1989
|
const intentSection = `## Routing intent\n\nintent: ${intent}\n${intent === 'exact_certified_lookup'
|
|
1395
1990
|
? 'Use a certified artifact only if it exactly answers the question.'
|
|
1396
1991
|
: 'Prepare a review-required DQL artifact with a SQL preview for this question. Certified blocks are trusted context, not a reason to answer the wrong grain.'}`;
|
|
@@ -1459,7 +2054,7 @@ function renderContextPrompt(blocks, businessContext, others, activeTier, memory
|
|
|
1459
2054
|
const contextPackSection = contextPack
|
|
1460
2055
|
? `\n\n## Local metadata context pack\n\n${renderContextPackForPrompt(contextPack, budget)}`
|
|
1461
2056
|
: '';
|
|
1462
|
-
return `${intentSection}${budgetSection}\n\n${blockSection}${businessSection}${otherSection}${kgJoinSection}${schemaSection}${contextPackSection}${memorySection}${hintsSection}${conversationSection}${extraSection}${followUpSection}`;
|
|
2057
|
+
return `${intentSection}${budgetSection}${dialectSection}\n\n${blockSection}${businessSection}${otherSection}${kgJoinSection}${schemaSection}${contextPackSection}${memorySection}${hintsSection}${conversationSection}${extraSection}${followUpSection}`;
|
|
1463
2058
|
}
|
|
1464
2059
|
function buildKgJoinPathHints(kg, nodes, questionPlan) {
|
|
1465
2060
|
const candidates = nodes
|
|
@@ -2968,25 +3563,29 @@ async function generateProposalWithOptionalTools(input) {
|
|
|
2968
3563
|
signal: input.signal,
|
|
2969
3564
|
reasoningEffort: input.reasoningEffort,
|
|
2970
3565
|
};
|
|
2971
|
-
|
|
3566
|
+
// No tools → plain generation (nothing for the loop to drive).
|
|
3567
|
+
if (tools.length === 0) {
|
|
2972
3568
|
return input.provider.generate(input.messages, options);
|
|
2973
3569
|
}
|
|
2974
3570
|
const toolBudget = proposalToolBudgetForQuestion(input.questionPlan, input.intent, {
|
|
2975
3571
|
analysisDepth: input.analysisDepth,
|
|
2976
3572
|
reasoningEffort: input.reasoningEffort,
|
|
2977
3573
|
});
|
|
2978
|
-
const toolPolicy =
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
3574
|
+
const toolPolicy = [
|
|
3575
|
+
'You may use the supplied DQL tools to inspect semantic members, certified context, metadata context, and bounded repair options.',
|
|
3576
|
+
`Tool budget for this question: ${toolBudget.maxToolCalls} call(s) (${toolBudget.effortClass}: ${toolBudget.reason}). Stop as soon as a lane can answer.`,
|
|
3577
|
+
'Prefer a governed semantic compile (search_semantic_layer → compile_semantic_query) over hand-written SQL when the semantic layer contains the requested metric/dimensions/time grain.',
|
|
3578
|
+
'When the supplied context is missing a table, column, or join key, DISCOVER it with `search_metadata` (find candidate relations) then `get_table_schema` (confirm real columns + inferred join keys) before declining. Use `search_project_files` for a bounded live source grep when indexed retrieval missed an identifier; use `scan_manifest` for cached graph objects; do not loop on the same failed context.',
|
|
3579
|
+
'When unsure a relation/column exists, validate a composed query with `validate_sql` rather than guessing.',
|
|
3580
|
+
'Final response must be a single ```json fenced object with summary, sql, viz, outputs, and optional dql metadata fields.',
|
|
3581
|
+
].join('\n');
|
|
3582
|
+
// runAgenticToolLoop drives the loop over ANY provider: native tool use where the
|
|
3583
|
+
// provider implements generateWithTools (Claude/OpenAI), and an equivalent text
|
|
3584
|
+
// protocol otherwise (subscription-CLI passthrough, Ollama). This is what gives
|
|
3585
|
+
// every provider — not just the two API ones — a real tool-driven Stage B.
|
|
3586
|
+
return runAgenticToolLoop(input.provider, [...input.messages], tools, {
|
|
2989
3587
|
...options,
|
|
3588
|
+
toolPolicy,
|
|
2990
3589
|
maxToolCalls: toolBudget.maxToolCalls,
|
|
2991
3590
|
onToolCall: (event) => {
|
|
2992
3591
|
const sink = input.toolCalls;
|
|
@@ -3130,8 +3729,14 @@ async function generateDeepAlternativeProposals(input) {
|
|
|
3130
3729
|
'Create a fourth candidate by DECOMPOSITION: break the question into sub-questions, solve each as a CTE, then compose the final SELECT — avoiding assumptions hidden in the first candidate.',
|
|
3131
3730
|
];
|
|
3132
3731
|
const temperatures = [0.2, 0.35, 0.5];
|
|
3133
|
-
|
|
3134
|
-
for
|
|
3732
|
+
// How many diverse alternatives to generate is set by the question SHAPE (S1):
|
|
3733
|
+
// a lightweight 1-candidate agreement check for join/breakdown shapes, the full
|
|
3734
|
+
// 3-candidate vote for deep-research, none for a single-table lookup. The
|
|
3735
|
+
// generations are independent, so they run in PARALLEL — the deep vote's
|
|
3736
|
+
// wall-clock cost is one generation, not the serial sum of all of them.
|
|
3737
|
+
const count = Math.max(0, Math.min(input.maxAlternatives ?? variants.length, variants.length));
|
|
3738
|
+
const selected = variants.slice(0, count);
|
|
3739
|
+
const results = await Promise.all(selected.map(async (instruction, index) => {
|
|
3135
3740
|
try {
|
|
3136
3741
|
const raw = await input.provider.generate([
|
|
3137
3742
|
...input.messages,
|
|
@@ -3151,14 +3756,14 @@ async function generateDeepAlternativeProposals(input) {
|
|
|
3151
3756
|
reasoningEffort: input.reasoningEffort,
|
|
3152
3757
|
temperature: temperatures[index] ?? 0.4,
|
|
3153
3758
|
});
|
|
3154
|
-
|
|
3155
|
-
out.push(raw);
|
|
3759
|
+
return raw.trim() ? raw : '';
|
|
3156
3760
|
}
|
|
3157
3761
|
catch {
|
|
3158
3762
|
// Alternative candidates are opportunistic; the initial candidate remains.
|
|
3763
|
+
return '';
|
|
3159
3764
|
}
|
|
3160
|
-
}
|
|
3161
|
-
return
|
|
3765
|
+
}));
|
|
3766
|
+
return results.filter((raw) => raw.trim());
|
|
3162
3767
|
}
|
|
3163
3768
|
function cloneParsedProposal(proposal) {
|
|
3164
3769
|
return {
|
|
@@ -3190,6 +3795,7 @@ function evidenceToolCallFromEvent(event, order) {
|
|
|
3190
3795
|
inputSummary: summarizeEvidencePayload(event.input),
|
|
3191
3796
|
outputSummary: summarizeEvidencePayload(event.output),
|
|
3192
3797
|
order,
|
|
3798
|
+
...(typeof event.durationMs === 'number' ? { durationMs: event.durationMs } : {}),
|
|
3193
3799
|
};
|
|
3194
3800
|
}
|
|
3195
3801
|
function summarizeEvidencePayload(value, maxLength = 700) {
|
|
@@ -3295,6 +3901,40 @@ function certifiedFreshnessCaveat(artifact) {
|
|
|
3295
3901
|
return undefined;
|
|
3296
3902
|
}
|
|
3297
3903
|
}
|
|
3904
|
+
function provenanceSourceTierLabel(tier) {
|
|
3905
|
+
switch (tier) {
|
|
3906
|
+
case 'certified_artifact': return 'Certified block';
|
|
3907
|
+
case 'semantic_layer': return 'Governed semantic metric';
|
|
3908
|
+
case 'business_context': return 'Business context';
|
|
3909
|
+
case 'dbt_manifest': return 'Generated SQL';
|
|
3910
|
+
default: return undefined;
|
|
3911
|
+
}
|
|
3912
|
+
}
|
|
3913
|
+
/**
|
|
3914
|
+
* Anthropic-style provenance footer: one line telling a stakeholder WHERE the
|
|
3915
|
+
* answer came from (source tier), how much to trust it, who owns the source, and
|
|
3916
|
+
* whether the underlying data is current. Built once at the single exit from the
|
|
3917
|
+
* finished answer; omitted for no-answer outcomes.
|
|
3918
|
+
*/
|
|
3919
|
+
export function buildProvenanceFooter(result, trust) {
|
|
3920
|
+
if (result.kind === 'no_answer')
|
|
3921
|
+
return undefined;
|
|
3922
|
+
const parts = [];
|
|
3923
|
+
const tierLabel = provenanceSourceTierLabel(result.sourceTier);
|
|
3924
|
+
if (tierLabel)
|
|
3925
|
+
parts.push(`Source: ${tierLabel}`);
|
|
3926
|
+
if (trust?.display)
|
|
3927
|
+
parts.push(`Trust: ${trust.display}`);
|
|
3928
|
+
if (result.block?.owner)
|
|
3929
|
+
parts.push(`Owner: ${result.block.owner}`);
|
|
3930
|
+
const freshness = result.block?.dataState === 'stale' ? 'stale — verify currency'
|
|
3931
|
+
: result.block?.dataState === 'failed' ? 'upstream run failed'
|
|
3932
|
+
: result.block?.dataState === 'fresh' ? 'current'
|
|
3933
|
+
: undefined;
|
|
3934
|
+
if (freshness)
|
|
3935
|
+
parts.push(`Data: ${freshness}`);
|
|
3936
|
+
return parts.length > 0 ? parts.join(' · ') : undefined;
|
|
3937
|
+
}
|
|
3298
3938
|
function mergeHits(...groups) {
|
|
3299
3939
|
const byId = new Map();
|
|
3300
3940
|
for (const group of groups) {
|
|
@@ -3316,6 +3956,53 @@ function mergeNodes(...groups) {
|
|
|
3316
3956
|
}
|
|
3317
3957
|
return Array.from(byId.values());
|
|
3318
3958
|
}
|
|
3959
|
+
/**
|
|
3960
|
+
* Merge prioritized hit groups into a single deduped list under a total budget,
|
|
3961
|
+
* giving each group a guaranteed minimum number of slots. Pass 1 takes up to
|
|
3962
|
+
* `reserve` from each group in priority order; pass 2 fills any remaining budget
|
|
3963
|
+
* round-robin from each group's leftovers. This prevents a high-priority group
|
|
3964
|
+
* from starving a lower one out of the prompt entirely (the semantic-hit
|
|
3965
|
+
* truncation bug) while still honoring priority for the reserved slots.
|
|
3966
|
+
*/
|
|
3967
|
+
function interleaveContextHits(groups, budget) {
|
|
3968
|
+
const seen = new Set();
|
|
3969
|
+
const out = [];
|
|
3970
|
+
const cursors = groups.map(() => 0);
|
|
3971
|
+
const take = (hit) => {
|
|
3972
|
+
if (out.length >= budget || seen.has(hit.node.nodeId))
|
|
3973
|
+
return false;
|
|
3974
|
+
seen.add(hit.node.nodeId);
|
|
3975
|
+
out.push(hit);
|
|
3976
|
+
return true;
|
|
3977
|
+
};
|
|
3978
|
+
// Pass 1: reserved minimums, in priority order.
|
|
3979
|
+
groups.forEach((group, gi) => {
|
|
3980
|
+
let taken = 0;
|
|
3981
|
+
while (cursors[gi] < group.hits.length && taken < group.reserve && out.length < budget) {
|
|
3982
|
+
if (take(group.hits[cursors[gi]]))
|
|
3983
|
+
taken += 1;
|
|
3984
|
+
cursors[gi] += 1;
|
|
3985
|
+
}
|
|
3986
|
+
});
|
|
3987
|
+
// Pass 2: round-robin fill from leftovers until the budget is exhausted.
|
|
3988
|
+
let progressed = true;
|
|
3989
|
+
while (out.length < budget && progressed) {
|
|
3990
|
+
progressed = false;
|
|
3991
|
+
for (let gi = 0; gi < groups.length && out.length < budget; gi += 1) {
|
|
3992
|
+
const group = groups[gi];
|
|
3993
|
+
while (cursors[gi] < group.hits.length) {
|
|
3994
|
+
const before = out.length;
|
|
3995
|
+
take(group.hits[cursors[gi]]);
|
|
3996
|
+
cursors[gi] += 1;
|
|
3997
|
+
if (out.length > before) {
|
|
3998
|
+
progressed = true;
|
|
3999
|
+
break;
|
|
4000
|
+
}
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
}
|
|
4004
|
+
return out;
|
|
4005
|
+
}
|
|
3319
4006
|
/**
|
|
3320
4007
|
* Candidate metric KG nodes for semantic-metric matching (spec 17, part C).
|
|
3321
4008
|
* Starts with the FTS semantic + considered hits, then folds in EVERY metric
|