@duckcodeailabs/dql-agent 1.6.34 → 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 +14 -1
- package/dist/agent-run-engine.d.ts.map +1 -1
- package/dist/agent-run-engine.js +123 -3
- 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 +6 -0
- package/dist/agent-run-gates.js.map +1 -1
- package/dist/answer-loop.d.ts +57 -4
- package/dist/answer-loop.d.ts.map +1 -1
- package/dist/answer-loop.js +435 -44
- 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.map +1 -1
- package/dist/cascade/budgets.js +8 -3
- package/dist/cascade/budgets.js.map +1 -1
- package/dist/cascade/route-policy.d.ts.map +1 -1
- package/dist/cascade/route-policy.js +5 -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/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/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 +7 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -2
- package/dist/index.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.map +1 -1
- package/dist/kg/sqlite-fts.js +8 -3
- 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/metadata/analysis-planner.d.ts.map +1 -1
- package/dist/metadata/analysis-planner.js +134 -18
- 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-fit.js +37 -4
- package/dist/metadata/block-fit.js.map +1 -1
- package/dist/metadata/catalog.d.ts +46 -1
- package/dist/metadata/catalog.d.ts.map +1 -1
- package/dist/metadata/catalog.js +313 -51
- 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/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.map +1 -1
- package/dist/metadata/sql-grounding.js +25 -2
- package/dist/metadata/sql-grounding.js.map +1 -1
- package/dist/metadata/sql-retrieval.d.ts +2 -0
- package/dist/metadata/sql-retrieval.d.ts.map +1 -1
- package/dist/metadata/sql-retrieval.js +5 -1
- 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 +199 -19
- package/dist/propose/build-from-prompt.js.map +1 -1
- package/dist/semantic-bridge/compose.d.ts.map +1 -1
- package/dist/semantic-bridge/compose.js +169 -5
- package/dist/semantic-bridge/compose.js.map +1 -1
- package/dist/skills/loader.d.ts +10 -0
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +81 -10
- 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 +42 -0
- package/dist/tools/registry.js.map +1 -1
- package/package.json +4 -4
package/dist/answer-loop.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { describeDialectForPrompt, } from '@duckcodeailabs/dql-core';
|
|
18
18
|
import { buildSkillBlockHints, buildSkillMetricHints, buildSkillsPrompt, expandQuestionWithSkillVocabulary, selectRelevantSkills } from './skills/loader.js';
|
|
19
|
+
import { domainContextSearchDomains } from './domain-context.js';
|
|
19
20
|
import { deriveGeneratedDraftSlug, renderGeneratedSqlDqlArtifact } from './metadata/drafts.js';
|
|
20
21
|
import { buildAnalysisQuestionPlan } from './metadata/analysis-planner.js';
|
|
21
22
|
import { certifiedFitAllowsTier1, evaluateCertifiedBlockFit } from './metadata/block-fit.js';
|
|
@@ -24,6 +25,8 @@ import { decideAgentAction } from './intent-controller.js';
|
|
|
24
25
|
import { createContextLedger } from './grounding/context-ledger.js';
|
|
25
26
|
import { validateAnswerResultShape } from './answer-shape.js';
|
|
26
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';
|
|
27
30
|
import { planCertifiedAdaptation } from './metadata/block-adapt.js';
|
|
28
31
|
import { compactSqlSnippet, extractSimpleSelectShape, selectExpressionOutputName, } from './metadata/sql-shape.js';
|
|
29
32
|
import { composeSemanticQueryForQuestion, composeSemanticQueryFromMembers } from './semantic-bridge/compose.js';
|
|
@@ -63,7 +66,7 @@ const EXECUTABLE_ARTIFACT_KINDS = ['block', 'dashboard', 'app', 'notebook'];
|
|
|
63
66
|
// be cited, but they never produce the row-level answer to an analytical question,
|
|
64
67
|
// so they must not terminate a data ask as a "certified answer" with no data.
|
|
65
68
|
const NAVIGATION_ARTIFACT_KINDS = ['dashboard', 'app', 'notebook'];
|
|
66
|
-
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'];
|
|
67
70
|
const ARTIFACT_KINDS = [...EXECUTABLE_ARTIFACT_KINDS, ...BUSINESS_CONTEXT_KINDS];
|
|
68
71
|
const SEMANTIC_KINDS = ['metric', 'dimension', 'measure', 'entity', 'semantic_model', 'saved_query'];
|
|
69
72
|
const MANIFEST_KINDS = ['dbt_model', 'dbt_source'];
|
|
@@ -75,6 +78,46 @@ function refusalCodeForValidation(code) {
|
|
|
75
78
|
return 'ambiguous';
|
|
76
79
|
return 'model_declined';
|
|
77
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
|
+
}
|
|
78
121
|
function formatOffendingValidationToken(offending) {
|
|
79
122
|
if (!offending?.relation && !offending?.column)
|
|
80
123
|
return '';
|
|
@@ -166,6 +209,7 @@ export async function answer(input) {
|
|
|
166
209
|
const trustLabelInfo = stampTrustLabel(result);
|
|
167
210
|
return {
|
|
168
211
|
...publicResult,
|
|
212
|
+
domainContext: input.domainContext,
|
|
169
213
|
intentDecision,
|
|
170
214
|
trustLabelInfo,
|
|
171
215
|
provenanceFooter: buildProvenanceFooter(result, trustLabelInfo),
|
|
@@ -188,7 +232,9 @@ export async function answer(input) {
|
|
|
188
232
|
appliedSkills: result.appliedSkills ??
|
|
189
233
|
selectRelevantSkills(input.skills ?? [], input.question, {
|
|
190
234
|
userId: input.userId ?? null,
|
|
235
|
+
modelAreaIds: input.domainContext?.modelAreaId ? [input.domainContext.modelAreaId] : [],
|
|
191
236
|
domains: Array.from(new Set([
|
|
237
|
+
...domainContextSearchDomains(input.domainContext),
|
|
192
238
|
...(input.domain ? [input.domain] : []),
|
|
193
239
|
...(input.contextPack?.objects ?? []).slice(0, 20).flatMap((object) => object.domain ? [object.domain] : []),
|
|
194
240
|
])),
|
|
@@ -219,6 +265,12 @@ function cascadeExecutionStatus(result) {
|
|
|
219
265
|
*/
|
|
220
266
|
function deriveAiRoute(result, metricMatch) {
|
|
221
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
|
+
}
|
|
222
274
|
return { tier: 'no_answer', label: 'No governed answer — needs more context or review.' };
|
|
223
275
|
}
|
|
224
276
|
if (result.kind === 'certified') {
|
|
@@ -251,16 +303,37 @@ function deriveAiRoute(result, metricMatch) {
|
|
|
251
303
|
}
|
|
252
304
|
async function runAnswerLoop(input) {
|
|
253
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;
|
|
254
321
|
// Select the RELEVANT skills (not all) for this question; keep pinned project
|
|
255
322
|
// skills (SQL conventions). Block hints still come from the full set so a
|
|
256
323
|
// preferred-block mapping is never lost.
|
|
324
|
+
const authorizedDomains = domainContextSearchDomains(input.domainContext);
|
|
257
325
|
const inferredDomains = Array.from(new Set([
|
|
326
|
+
...authorizedDomains,
|
|
327
|
+
...questionDomainScope,
|
|
258
328
|
...(domain ? [domain] : []),
|
|
259
|
-
...(
|
|
329
|
+
...(questionDomainScope.length === 0
|
|
330
|
+
? (input.contextPack?.objects ?? []).slice(0, 20).flatMap((object) => object.domain ? [object.domain] : [])
|
|
331
|
+
: []),
|
|
260
332
|
]));
|
|
261
333
|
const selectedSkills = selectRelevantSkills(skills, question, {
|
|
262
334
|
userId: userId ?? null,
|
|
263
335
|
domains: inferredDomains,
|
|
336
|
+
modelAreaIds: input.domainContext?.modelAreaId ? [input.domainContext.modelAreaId] : [],
|
|
264
337
|
});
|
|
265
338
|
const effectiveBlockHints = Array.from(new Set([
|
|
266
339
|
...blockHints,
|
|
@@ -276,13 +349,14 @@ async function runAnswerLoop(input) {
|
|
|
276
349
|
const excludedArtifactIds = input.followUp?.kind === 'drilldown' && followUpSourceBlock
|
|
277
350
|
? new Set([followUpSourceBlock.nodeId])
|
|
278
351
|
: undefined;
|
|
279
|
-
const
|
|
280
|
-
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 });
|
|
281
355
|
const artifactHits = mergeHits(executableArtifactHits, businessHits).slice(0, 12);
|
|
282
|
-
const semanticHits = kg.search({ query: question,
|
|
283
|
-
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 });
|
|
284
358
|
const considered = mergeHits(artifactHits, semanticHits, manifestHits, kg.search({ query: question, domain, limit: 10 })).slice(0, 30);
|
|
285
|
-
const schemaContext = schemaContextWithAllowedSqlContext(input.schemaContext ?? [], input.contextPack);
|
|
359
|
+
const schemaContext = schemaContextWithAllowedSqlContext(schemaContextWithinQuestionScope(input.schemaContext ?? [], input.contextPack, promptContextPack), promptContextPack);
|
|
286
360
|
const catalogRoute = input.contextPack?.routeDecision;
|
|
287
361
|
const questionPlan = input.contextPack?.questionPlan?.requestedShape
|
|
288
362
|
? input.contextPack.questionPlan
|
|
@@ -370,30 +444,80 @@ async function runAnswerLoop(input) {
|
|
|
370
444
|
if (NAVIGATION_ARTIFACT_KINDS.includes(artifactHit.node.kind)) {
|
|
371
445
|
return objectNameInQuestion(question, artifactHit.node);
|
|
372
446
|
}
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
// "
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
const strongShape = nonTrivialOutputs.length > 0
|
|
381
|
-
|| requested.filters.length > 0
|
|
382
|
-
|| Boolean(requested.topN);
|
|
383
|
-
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);
|
|
384
454
|
})();
|
|
385
455
|
if (artifactHit && businessContextTerminal) {
|
|
386
456
|
let result;
|
|
387
457
|
let executionError;
|
|
388
458
|
if (artifactHit.node.kind === 'block' && input.executeCertifiedBlock) {
|
|
389
459
|
try {
|
|
390
|
-
result = await input.executeCertifiedBlock(artifactHit.node);
|
|
460
|
+
result = await input.executeCertifiedBlock(artifactHit.node, { question });
|
|
391
461
|
result = trimResultToRequestedTopN(result, questionPlan);
|
|
392
462
|
}
|
|
393
463
|
catch (err) {
|
|
394
464
|
executionError = err instanceof Error ? err.message : String(err);
|
|
395
465
|
}
|
|
396
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
|
+
}
|
|
397
521
|
const resultShapeWarnings = result ? validateAnswerResultShape(questionPlan, result).warnings : [];
|
|
398
522
|
// When a certified block's execution was ATTEMPTED and FAILED, the answer
|
|
399
523
|
// cannot wear the certified badge — a failed run has no data to stand behind.
|
|
@@ -573,7 +697,7 @@ async function runAnswerLoop(input) {
|
|
|
573
697
|
{ hits: businessHits.slice(0, 4), reserve: 3 },
|
|
574
698
|
{ hits: manifestHits, reserve: 3 },
|
|
575
699
|
], 14);
|
|
576
|
-
const contextNodes = mergeNodes(followUpSourceBlock && input.followUp?.kind === 'drilldown' ? [followUpSourceBlock] : [], (contextHits.length > 0 ? contextHits : considered.slice(0, 6)).map((h) => h.node));
|
|
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));
|
|
577
701
|
const kgJoinPathHints = buildKgJoinPathHints(kg, contextNodes, questionPlan);
|
|
578
702
|
const contextBlocks = contextNodes.filter((n) => n.kind === 'block');
|
|
579
703
|
const contextBusiness = contextNodes.filter((n) => BUSINESS_CONTEXT_KINDS.includes(n.kind));
|
|
@@ -589,9 +713,32 @@ async function runAnswerLoop(input) {
|
|
|
589
713
|
const skillsPrompt = buildSkillsPrompt(selectedSkills, userId ?? null);
|
|
590
714
|
if (skillsPrompt)
|
|
591
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
|
+
}
|
|
592
739
|
messages.push({
|
|
593
740
|
role: 'system',
|
|
594
|
-
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),
|
|
595
742
|
});
|
|
596
743
|
messages.push({ role: 'user', content: question });
|
|
597
744
|
// ── Tier 2: semantic-layer metrics + dimensions (governed hierarchy) ──────
|
|
@@ -904,7 +1051,7 @@ async function runAnswerLoop(input) {
|
|
|
904
1051
|
|| (input.contextPack?.allowedSqlContext?.relations.length ?? 0) > 0
|
|
905
1052
|
|| (input.contextPack?.allowedSqlContext?.sourceBlockSql.length ?? 0) > 0
|
|
906
1053
|
|| contextBlocks.length > 0;
|
|
907
|
-
if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext) {
|
|
1054
|
+
if (!parsed.sql && !governedMetricAnswer && wantsGeneratedData && hasGeneratableContext && analyticalPlan?.safe !== false) {
|
|
908
1055
|
try {
|
|
909
1056
|
proposed = await generateProposalWithOptionalTools({
|
|
910
1057
|
provider,
|
|
@@ -933,9 +1080,11 @@ async function runAnswerLoop(input) {
|
|
|
933
1080
|
// every run and every surface — instead of passing through the model's varying
|
|
934
1081
|
// text. A genuinely context-less ask keeps the plain honest message.
|
|
935
1082
|
const declinedDespiteContext = wantsGeneratedData && hasGeneratableContext;
|
|
936
|
-
const text =
|
|
937
|
-
? '
|
|
938
|
-
:
|
|
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).';
|
|
939
1088
|
return {
|
|
940
1089
|
kind: 'no_answer',
|
|
941
1090
|
sourceTier: 'no_answer',
|
|
@@ -943,9 +1092,13 @@ async function runAnswerLoop(input) {
|
|
|
943
1092
|
reviewStatus: 'none',
|
|
944
1093
|
confidence: 0.1,
|
|
945
1094
|
text,
|
|
946
|
-
refusalCode:
|
|
1095
|
+
refusalCode: analyticalPlan?.safe === false
|
|
1096
|
+
? refusalCodeForAnalyticalPolicy(analyticalPlan.code)
|
|
1097
|
+
: 'model_declined',
|
|
947
1098
|
refusalDetails: {
|
|
948
|
-
code:
|
|
1099
|
+
code: analyticalPlan?.safe === false
|
|
1100
|
+
? analyticalPlan.code ?? 'unsafe_relationship'
|
|
1101
|
+
: 'model_declined',
|
|
949
1102
|
message: text,
|
|
950
1103
|
},
|
|
951
1104
|
answer: text,
|
|
@@ -981,20 +1134,27 @@ async function runAnswerLoop(input) {
|
|
|
981
1134
|
&& questionPlan.requestedShape.filters.length === 0
|
|
982
1135
|
&& !questionPlan.requestedShape.topN;
|
|
983
1136
|
let contextValidation;
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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;
|
|
998
1158
|
// Spec 17, part C — semantic-metric route recovery. A metric question is often
|
|
999
1159
|
// catalog-routed to clarify, leaving a thin contextPack that rejects otherwise-valid
|
|
1000
1160
|
// SQL. Rather than refuse, recover in two steps:
|
|
@@ -1188,6 +1348,70 @@ async function runAnswerLoop(input) {
|
|
|
1188
1348
|
providerUsed: provider.name,
|
|
1189
1349
|
};
|
|
1190
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
|
+
}
|
|
1191
1415
|
const generatedCitations = [
|
|
1192
1416
|
...contextPackCitations(input.contextPack, 4),
|
|
1193
1417
|
...contextNodes.slice(0, 4).map((n) => ({
|
|
@@ -1535,12 +1759,26 @@ Rules:
|
|
|
1535
1759
|
Design the SELECT for a business reader: prefer a joined/display name, label,
|
|
1536
1760
|
title, or business value over raw *_id, *_uuid, *_key, or technical codes.
|
|
1537
1761
|
Alias calculated outputs with clear business names. Include identifiers only
|
|
1538
|
-
when the question asks for them or no readable field exists.
|
|
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.
|
|
1539
1772
|
4. In summary, state your QUERY PLAN first: the grain (one row per WHAT), the
|
|
1540
1773
|
measures and how they aggregate, the dimensions/filters, and the exact join
|
|
1541
1774
|
path + join keys between the grounded tables. Then make the SQL match that
|
|
1542
1775
|
plan — an explicit grain and join path prevents wrong-grain answers and
|
|
1543
|
-
fan-out (row-multiplying) joins.
|
|
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.
|
|
1544
1782
|
5. Choose a visualization deliberately in the "viz" JSON field: use single_value
|
|
1545
1783
|
or kpi for one aggregate, line/area only for an ordered time series, bar for a
|
|
1546
1784
|
categorical comparison, grouped-bar for multiple measures by one category,
|
|
@@ -1581,12 +1819,165 @@ Rules:
|
|
|
1581
1819
|
// combined dataset — show them separately" refusal into the join the user asked
|
|
1582
1820
|
// for, while still allowing an honest refusal if context is truly missing.
|
|
1583
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.`;
|
|
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
|
+
}
|
|
1584
1975
|
// Metric-anchored generation (Tier 2.5): a governed metric matched the question but
|
|
1585
1976
|
// the semantic layer couldn't compose the exact shape. Reuse the metric's CERTIFIED
|
|
1586
1977
|
// definition as the measure rather than reinventing it, so the number stays consistent
|
|
1587
1978
|
// with the governed metric — only the join/grouping around it is generated.
|
|
1588
1979
|
function metricAnchorInstruction(metricName, def) {
|
|
1589
|
-
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
|
|
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.`;
|
|
1590
1981
|
}
|
|
1591
1982
|
function renderContextPrompt(blocks, businessContext, others, activeTier, memoryContext, extraContext, followUp, schemaContext = [], intent = 'ad_hoc_analysis', contextPack, conversationSnapshot, kgJoinPathHints = [], budget = QUICK_PROMPT_CONTEXT_BUDGET, driver) {
|
|
1592
1983
|
// W1.5 — dialect conventions so the model writes warehouse-correct SQL
|